From 53b42905f17553b74160eaae7d5352fc7aa648d7 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Wed, 29 May 2019 00:42:09 +0530 Subject: [PATCH] Convert autofill package to Kotlin (#515) Signed-off-by: Harsh Shandilya --- .../pwdstore/autofill/AutofillActivity.java | 101 --- .../pwdstore/autofill/AutofillActivity.kt | 88 +++ .../pwdstore/autofill/AutofillFragment.java | 235 ------- .../pwdstore/autofill/AutofillFragment.kt | 211 ++++++ .../autofill/AutofillPreferenceActivity.java | 165 ----- .../autofill/AutofillPreferenceActivity.kt | 159 +++++ .../autofill/AutofillRecyclerAdapter.java | 192 ------ .../autofill/AutofillRecyclerAdapter.kt | 170 +++++ .../pwdstore/autofill/AutofillService.java | 606 ------------------ .../pwdstore/autofill/AutofillService.kt | 582 +++++++++++++++++ .../com/zeapo/pwdstore/crypto/PgpActivity.kt | 26 +- .../com/zeapo/pwdstore/utils/Extensions.kt | 5 + 12 files changed, 1228 insertions(+), 1312 deletions(-) delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.java create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.java create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.java create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.java create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.java create mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.java b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.java deleted file mode 100644 index 12d1de74..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.zeapo.pwdstore.autofill; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.IntentSender; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import androidx.appcompat.app.AppCompatActivity; -import com.zeapo.pwdstore.PasswordStore; -import org.eclipse.jgit.util.StringUtils; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -// blank activity started by service for calling startIntentSenderForResult -public class AutofillActivity extends AppCompatActivity { - public static final int REQUEST_CODE_DECRYPT_AND_VERIFY = 9913; - public static final int REQUEST_CODE_PICK = 777; - public static final int REQUEST_CODE_PICK_MATCH_WITH = 778; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Bundle extras = getIntent().getExtras(); - - if (extras != null && extras.containsKey("pending_intent")) { - try { - PendingIntent pi = extras.getParcelable("pending_intent"); - if (pi == null) { - return; - } - startIntentSenderForResult(pi.getIntentSender() - , REQUEST_CODE_DECRYPT_AND_VERIFY, null, 0, 0, 0); - } catch (IntentSender.SendIntentException e) { - Log.e(AutofillService.Constants.TAG, "SendIntentException", e); - } - } else if (extras != null && extras.containsKey("pick")) { - Intent intent = new Intent(getApplicationContext(), PasswordStore.class); - intent.putExtra("matchWith", true); - startActivityForResult(intent, REQUEST_CODE_PICK); - } else if (extras != null && extras.containsKey("pickMatchWith")) { - Intent intent = new Intent(getApplicationContext(), PasswordStore.class); - intent.putExtra("matchWith", true); - startActivityForResult(intent, REQUEST_CODE_PICK_MATCH_WITH); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - finish(); // go back to the password field app - switch (requestCode) { - case REQUEST_CODE_DECRYPT_AND_VERIFY: - if (resultCode == RESULT_OK) { - AutofillService.getInstance().setResultData(data); // report the result to service - } - break; - case REQUEST_CODE_PICK: - if (resultCode == RESULT_OK) { - AutofillService.getInstance().setPickedPassword(data.getStringExtra("path")); - } - break; - case REQUEST_CODE_PICK_MATCH_WITH: - if (resultCode == RESULT_OK) { - // need to not only decrypt the picked password, but also - // update the "match with" preference - Bundle extras = getIntent().getExtras(); - String packageName = extras.getString("packageName"); - boolean isWeb = extras.getBoolean("isWeb"); - - String path = data.getStringExtra("path"); - AutofillService.getInstance().setPickedPassword(data.getStringExtra("path")); - - SharedPreferences prefs; - if (!isWeb) { - prefs = getApplicationContext().getSharedPreferences("autofill", Context.MODE_PRIVATE); - } else { - prefs = getApplicationContext().getSharedPreferences("autofill_web", Context.MODE_PRIVATE); - } - SharedPreferences.Editor editor = prefs.edit(); - String preference = prefs.getString(packageName, ""); - switch (preference) { - case "": - case "/first": - case "/never": - editor.putString(packageName, path); - break; - default: - List matches = new ArrayList<>(Arrays.asList(preference.trim().split("\n"))); - matches.add(path); - String paths = StringUtils.join(matches, "\n"); - editor.putString(packageName, paths); - } - editor.apply(); - } - break; - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.kt new file mode 100644 index 00000000..9bf2f085 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillActivity.kt @@ -0,0 +1,88 @@ +package com.zeapo.pwdstore.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.zeapo.pwdstore.PasswordStore +import com.zeapo.pwdstore.utils.splitLines +import org.eclipse.jgit.util.StringUtils +import java.util.ArrayList +import java.util.Arrays + +// blank activity started by service for calling startIntentSenderForResult +class AutofillActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val extras = intent.extras + + if (extras != null && extras.containsKey("pending_intent")) { + try { + val pi = extras.getParcelable("pending_intent") ?: return + startIntentSenderForResult(pi.intentSender, REQUEST_CODE_DECRYPT_AND_VERIFY, null, 0, 0, 0) + } catch (e: IntentSender.SendIntentException) { + Log.e(AutofillService.Constants.TAG, "SendIntentException", e) + } + + } else if (extras != null && extras.containsKey("pick")) { + val intent = Intent(applicationContext, PasswordStore::class.java) + intent.putExtra("matchWith", true) + startActivityForResult(intent, REQUEST_CODE_PICK) + } else if (extras != null && extras.containsKey("pickMatchWith")) { + val intent = Intent(applicationContext, PasswordStore::class.java) + intent.putExtra("matchWith", true) + startActivityForResult(intent, REQUEST_CODE_PICK_MATCH_WITH) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + finish() // go back to the password field app + when (requestCode) { + REQUEST_CODE_DECRYPT_AND_VERIFY -> if (resultCode == RESULT_OK) { + AutofillService.instance?.setResultData(data!!) // report the result to service + } + REQUEST_CODE_PICK -> if (resultCode == RESULT_OK) { + AutofillService.instance?.setPickedPassword(data!!.getStringExtra("path")) + } + REQUEST_CODE_PICK_MATCH_WITH -> if (resultCode == RESULT_OK) { + // need to not only decrypt the picked password, but also + // update the "match with" preference + val extras = intent.extras ?: return + val packageName = extras.getString("packageName") + val isWeb = extras.getBoolean("isWeb") + + val path = data!!.getStringExtra("path") + AutofillService.instance?.setPickedPassword(data.getStringExtra("path")) + + val prefs: SharedPreferences + prefs = if (!isWeb) { + applicationContext.getSharedPreferences("autofill", Context.MODE_PRIVATE) + } else { + applicationContext.getSharedPreferences("autofill_web", Context.MODE_PRIVATE) + } + val editor = prefs.edit() + when (val preference = prefs.getString(packageName, "")) { + "", "/first", "/never" -> editor.putString(packageName, path) + else -> { + val matches = ArrayList(Arrays.asList(*preference!!.trim { it <= ' ' }.splitLines())) + matches.add(path) + val paths = StringUtils.join(matches, "\n") + editor.putString(packageName, paths) + } + } + editor.apply() + } + } + } + + companion object { + const val REQUEST_CODE_DECRYPT_AND_VERIFY = 9913 + const val REQUEST_CODE_PICK = 777 + const val REQUEST_CODE_PICK_MATCH_WITH = 778 + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.java b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.java deleted file mode 100644 index d7095dae..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.zeapo.pwdstore.autofill; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.Dialog; -import android.app.DialogFragment; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ListView; -import android.widget.RadioButton; -import android.widget.RadioGroup; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import com.zeapo.pwdstore.PasswordStore; -import com.zeapo.pwdstore.R; - -public class AutofillFragment extends DialogFragment { - private static final int MATCH_WITH = 777; - private ArrayAdapter adapter; - private boolean isWeb; - - public AutofillFragment() { - } - - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - // this fragment is only created from the settings page (AutofillPreferenceActivity) - // need to interact with the recyclerAdapter which is a member of activity - final AutofillPreferenceActivity callingActivity = (AutofillPreferenceActivity) getActivity(); - LayoutInflater inflater = callingActivity.getLayoutInflater(); - - @SuppressLint("InflateParams") final View view = inflater.inflate(R.layout.fragment_autofill, null); - - builder.setView(view); - - final String packageName = getArguments().getString("packageName"); - final String appName = getArguments().getString("appName"); - isWeb = getArguments().getBoolean("isWeb"); - - // set the dialog icon and title or webURL editText - String iconPackageName; - if (!isWeb) { - iconPackageName = packageName; - builder.setTitle(appName); - view.findViewById(R.id.webURL).setVisibility(View.GONE); - } else { - iconPackageName = "com.android.browser"; - builder.setTitle("Website"); - ((EditText) view.findViewById(R.id.webURL)).setText(packageName); - } - try { - builder.setIcon(callingActivity.getPackageManager().getApplicationIcon(iconPackageName)); - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } - - // set up the listview now for items added by button/from preferences - adapter = new ArrayAdapter(getActivity().getApplicationContext() - , android.R.layout.simple_list_item_1, android.R.id.text1) { - // set text color to black because default is white... - @NonNull - @Override - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - TextView textView = (TextView) super.getView(position, convertView, parent); - textView.setTextColor(ContextCompat.getColor(getContext(), R.color.grey_black_1000)); - return textView; - } - }; - ((ListView) view.findViewById(R.id.matched)).setAdapter(adapter); - // delete items by clicking them - ((ListView) view.findViewById(R.id.matched)).setOnItemClickListener( - (parent, view1, position, id) -> adapter.remove(adapter.getItem(position))); - - // set the existing preference, if any - SharedPreferences prefs; - if (!isWeb) { - prefs = getActivity().getApplicationContext().getSharedPreferences("autofill", Context.MODE_PRIVATE); - } else { - prefs = getActivity().getApplicationContext().getSharedPreferences("autofill_web", Context.MODE_PRIVATE); - } - String preference = prefs.getString(packageName, ""); - switch (preference) { - case "": - ((RadioButton) view.findViewById(R.id.use_default)).toggle(); - break; - case "/first": - ((RadioButton) view.findViewById(R.id.first)).toggle(); - break; - case "/never": - ((RadioButton) view.findViewById(R.id.never)).toggle(); - break; - default: - ((RadioButton) view.findViewById(R.id.match)).toggle(); - // trim to remove the last blank element - adapter.addAll(preference.trim().split("\n")); - } - - // add items with the + button - View.OnClickListener matchPassword = v -> { - ((RadioButton) view.findViewById(R.id.match)).toggle(); - Intent intent = new Intent(getActivity(), PasswordStore.class); - intent.putExtra("matchWith", true); - startActivityForResult(intent, MATCH_WITH); - }; - view.findViewById(R.id.matchButton).setOnClickListener(matchPassword); - - // write to preferences when OK clicked - builder.setPositiveButton(R.string.dialog_ok, (dialog, which) -> { - - }); - builder.setNegativeButton(R.string.dialog_cancel, null); - final SharedPreferences.Editor editor = prefs.edit(); - if (isWeb) { - builder.setNeutralButton(R.string.autofill_apps_delete, (dialog, which) -> { - if (callingActivity.recyclerAdapter != null - && packageName != null && !packageName.equals("")) { - editor.remove(packageName); - callingActivity.recyclerAdapter.removeWebsite(packageName); - editor.apply(); - } - }); - } - return builder.create(); - } - - // need to the onClick here for buttons to dismiss dialog only when wanted - @Override - public void onStart() { - super.onStart(); - AlertDialog ad = (AlertDialog) getDialog(); - if (ad != null) { - Button positiveButton = ad.getButton(Dialog.BUTTON_POSITIVE); - positiveButton.setOnClickListener(v -> { - AutofillPreferenceActivity callingActivity = (AutofillPreferenceActivity) getActivity(); - Dialog dialog = getDialog(); - - SharedPreferences prefs; - if (!isWeb) { - prefs = getActivity().getApplicationContext().getSharedPreferences("autofill", Context.MODE_PRIVATE); - } else { - prefs = getActivity().getApplicationContext().getSharedPreferences("autofill_web", Context.MODE_PRIVATE); - } - SharedPreferences.Editor editor = prefs.edit(); - - String packageName = getArguments().getString("packageName", ""); - if (isWeb) { - // handle some errors and don't dismiss the dialog - EditText webURL = dialog.findViewById(R.id.webURL); - - packageName = webURL.getText().toString(); - - if (packageName.equals("")) { - webURL.setError("URL cannot be blank"); - return; - } - String oldPackageName = getArguments().getString("packageName", ""); - if (!oldPackageName.equals(packageName) && prefs.getAll().containsKey(packageName)) { - webURL.setError("URL already exists"); - return; - } - } - - // write to preferences accordingly - RadioGroup radioGroup = dialog.findViewById(R.id.autofill_radiogroup); - switch (radioGroup.getCheckedRadioButtonId()) { - case R.id.use_default: - if (!isWeb) { - editor.remove(packageName); - } else { - editor.putString(packageName, ""); - } - break; - case R.id.first: - editor.putString(packageName, "/first"); - break; - case R.id.never: - editor.putString(packageName, "/never"); - break; - default: - StringBuilder paths = new StringBuilder(); - for (int i = 0; i < adapter.getCount(); i++) { - paths.append(adapter.getItem(i)); - if (i != adapter.getCount()) { - paths.append("\n"); - } - } - editor.putString(packageName, paths.toString()); - } - editor.apply(); - - // notify the recycler adapter if it is loaded - if (callingActivity.recyclerAdapter != null) { - int position; - if (!isWeb) { - String appName = getArguments().getString("appName", ""); - position = callingActivity.recyclerAdapter.getPosition(appName); - callingActivity.recyclerAdapter.notifyItemChanged(position); - } else { - position = callingActivity.recyclerAdapter.getPosition(packageName); - String oldPackageName = getArguments().getString("packageName", ""); - if (oldPackageName.equals(packageName)) { - callingActivity.recyclerAdapter.notifyItemChanged(position); - } else if (oldPackageName.equals("")) { - callingActivity.recyclerAdapter.addWebsite(packageName); - } else { - editor.remove(oldPackageName); - callingActivity.recyclerAdapter.updateWebsite(oldPackageName, packageName); - } - } - } - - dismiss(); - }); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode == Activity.RESULT_OK) { - adapter.add(data.getStringExtra("path")); - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.kt new file mode 100644 index 00000000..aa1c329b --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillFragment.kt @@ -0,0 +1,211 @@ +package com.zeapo.pwdstore.autofill + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.ListView +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import com.zeapo.pwdstore.PasswordStore +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.splitLines + +class AutofillFragment : DialogFragment() { + private var adapter: ArrayAdapter? = null + private var isWeb: Boolean = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = AlertDialog.Builder(requireContext()) + // this fragment is only created from the settings page (AutofillPreferenceActivity) + // need to interact with the recyclerAdapter which is a member of activity + val callingActivity = requireActivity() as AutofillPreferenceActivity + val inflater = callingActivity.layoutInflater + val args = requireNotNull(arguments) + + @SuppressLint("InflateParams") val view = inflater.inflate(R.layout.fragment_autofill, null) + + builder.setView(view) + + val packageName = args.getString("packageName") + val appName = args.getString("appName") + isWeb = args.getBoolean("isWeb") + + // set the dialog icon and title or webURL editText + val iconPackageName: String? + if (!isWeb) { + iconPackageName = packageName + builder.setTitle(appName) + view.findViewById(R.id.webURL).visibility = View.GONE + } else { + iconPackageName = "com.android.browser" + builder.setTitle("Website") + (view.findViewById(R.id.webURL) as EditText).setText(packageName) + } + try { + builder.setIcon(callingActivity.packageManager.getApplicationIcon(iconPackageName)) + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + } + + // set up the listview now for items added by button/from preferences + adapter = object : ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, android.R.id.text1) { + // set text color to black because default is white... + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val textView = super.getView(position, convertView, parent) as TextView + textView.setTextColor(ContextCompat.getColor(context, R.color.grey_black_1000)) + return textView + } + } + (view.findViewById(R.id.matched) as ListView).adapter = adapter + // delete items by clicking them + (view.findViewById(R.id.matched) as ListView).setOnItemClickListener { _, _, position, _ -> adapter!!.remove(adapter!!.getItem(position)) } + + // set the existing preference, if any + val prefs: SharedPreferences = if (!isWeb) { + callingActivity.applicationContext.getSharedPreferences("autofill", Context.MODE_PRIVATE) + } else { + callingActivity.applicationContext.getSharedPreferences("autofill_web", Context.MODE_PRIVATE) + } + when (val preference = prefs.getString(packageName, "")) { + "" -> (view.findViewById(R.id.use_default) as RadioButton).toggle() + "/first" -> (view.findViewById(R.id.first) as RadioButton).toggle() + "/never" -> (view.findViewById(R.id.never) as RadioButton).toggle() + else -> { + (view.findViewById(R.id.match) as RadioButton).toggle() + // trim to remove the last blank element + adapter!!.addAll(*preference!!.trim { it <= ' ' }.splitLines()) + } + } + + // add items with the + button + val matchPassword = { _: View -> + (view.findViewById(R.id.match) as RadioButton).toggle() + val intent = Intent(activity, PasswordStore::class.java) + intent.putExtra("matchWith", true) + startActivityForResult(intent, MATCH_WITH) + } + view.findViewById(R.id.matchButton).setOnClickListener(matchPassword) + + // write to preferences when OK clicked + builder.setPositiveButton(R.string.dialog_ok) { _, _ -> } + builder.setNegativeButton(R.string.dialog_cancel, null) + val editor = prefs.edit() + if (isWeb) { + builder.setNeutralButton(R.string.autofill_apps_delete) { _, _ -> + if (callingActivity.recyclerAdapter != null + && packageName != null && packageName != "") { + editor.remove(packageName) + callingActivity.recyclerAdapter?.removeWebsite(packageName) + editor.apply() + } + } + } + return builder.create() + } + + // need to the onClick here for buttons to dismiss dialog only when wanted + override fun onStart() { + super.onStart() + val ad = dialog as? AlertDialog + if (ad != null) { + val positiveButton = ad.getButton(Dialog.BUTTON_POSITIVE) + positiveButton.setOnClickListener { + val callingActivity = requireActivity() as AutofillPreferenceActivity + val dialog = dialog + val args = requireNotNull(arguments) + + val prefs: SharedPreferences = if (!isWeb) { + callingActivity.applicationContext.getSharedPreferences("autofill", Context.MODE_PRIVATE) + } else { + callingActivity.applicationContext.getSharedPreferences("autofill_web", Context.MODE_PRIVATE) + } + val editor = prefs.edit() + + var packageName = args.getString("packageName", "") + if (isWeb) { + // handle some errors and don't dismiss the dialog + val webURL = dialog.findViewById(R.id.webURL) + + packageName = webURL.text.toString() + + if (packageName == "") { + webURL.error = "URL cannot be blank" + return@setOnClickListener + } + val oldPackageName = args.getString("packageName", "") + if (oldPackageName != packageName && prefs.all.containsKey(packageName)) { + webURL.error = "URL already exists" + return@setOnClickListener + } + } + + // write to preferences accordingly + val radioGroup = dialog.findViewById(R.id.autofill_radiogroup) + when (radioGroup.checkedRadioButtonId) { + R.id.use_default -> if (!isWeb) { + editor.remove(packageName) + } else { + editor.putString(packageName, "") + } + R.id.first -> editor.putString(packageName, "/first") + R.id.never -> editor.putString(packageName, "/never") + else -> { + val paths = StringBuilder() + for (i in 0 until adapter!!.count) { + paths.append(adapter!!.getItem(i)) + if (i != adapter!!.count) { + paths.append("\n") + } + } + editor.putString(packageName, paths.toString()) + } + } + editor.apply() + + // notify the recycler adapter if it is loaded + callingActivity.recyclerAdapter?.apply { + val position: Int + if (!isWeb) { + val appName = args.getString("appName", "") + position = getPosition(appName) + notifyItemChanged(position) + } else { + position = getPosition(packageName) + when (val oldPackageName = args.getString("packageName", "")) { + packageName -> notifyItemChanged(position) + "" -> addWebsite(packageName) + else -> { + editor.remove(oldPackageName) + updateWebsite(oldPackageName, packageName) + } + } + } + } + dismiss() + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { + if (resultCode == AppCompatActivity.RESULT_OK) { + adapter!!.add(data.getStringExtra("path")) + } + } + + companion object { + private const val MATCH_WITH = 777 + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.java b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.java deleted file mode 100644 index 9fba959a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.zeapo.pwdstore.autofill; - -import android.app.DialogFragment; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.SearchView; -import androidx.core.app.NavUtils; -import androidx.core.app.TaskStackBuilder; -import androidx.core.view.MenuItemCompat; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.zeapo.pwdstore.R; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -public class AutofillPreferenceActivity extends AppCompatActivity { - - AutofillRecyclerAdapter recyclerAdapter; // let fragment have access - private RecyclerView recyclerView; - private PackageManager pm; - - private boolean recreate; // flag for action on up press; origin autofill dialog? different act - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.autofill_recycler_view); - recyclerView = findViewById(R.id.autofill_recycler); - - RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(this); - recyclerView.setLayoutManager(layoutManager); - recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL)); - - pm = getPackageManager(); - - new populateTask().execute(); - - // if the preference activity was started from the autofill dialog - recreate = false; - Bundle extras = getIntent().getExtras(); - if (extras != null) { - recreate = true; - - showDialog(extras.getString("packageName"), extras.getString("appName"), extras.getBoolean("isWeb")); - } - - setTitle("Autofill Apps"); - - final FloatingActionButton fab = findViewById(R.id.fab); - fab.setOnClickListener(v -> showDialog("", "", true)); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.autofill_preference, menu); - MenuItem searchItem = menu.findItem(R.id.action_search); - SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchItem); - - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String s) { - return false; - } - - @Override - public boolean onQueryTextChange(String s) { - if (recyclerAdapter != null) { - recyclerAdapter.filter(s); - } - return true; - } - }); - - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - // in service, we CLEAR_TASK. then we set the recreate flag. - // something of a hack, but w/o CLEAR_TASK, behaviour was unpredictable - case android.R.id.home: - Intent upIntent = NavUtils.getParentActivityIntent(this); - if (recreate) { - TaskStackBuilder.create(this) - .addNextIntentWithParentStack(upIntent) - .startActivities(); - } else { - NavUtils.navigateUpTo(this, upIntent); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - public void showDialog(String packageName, String appName, boolean isWeb) { - DialogFragment df = new AutofillFragment(); - Bundle args = new Bundle(); - args.putString("packageName", packageName); - args.putString("appName", appName); - args.putBoolean("isWeb", isWeb); - df.setArguments(args); - df.show(getFragmentManager(), "autofill_dialog"); - } - - private class populateTask extends AsyncTask { - @Override - protected void onPreExecute() { - runOnUiThread(() -> findViewById(R.id.progress_bar).setVisibility(View.VISIBLE)); - } - - @Override - protected Void doInBackground(Void... params) { - Intent intent = new Intent(Intent.ACTION_MAIN); - intent.addCategory(Intent.CATEGORY_LAUNCHER); - List allAppsResolveInfo = pm.queryIntentActivities(intent, 0); - List allApps = new ArrayList<>(); - - for (ResolveInfo app : allAppsResolveInfo) { - allApps.add(new AutofillRecyclerAdapter.AppInfo(app.activityInfo.packageName - , app.loadLabel(pm).toString(), false, app.loadIcon(pm))); - } - - SharedPreferences prefs = getSharedPreferences("autofill_web", Context.MODE_PRIVATE); - Map prefsMap = prefs.getAll(); - for (String key : prefsMap.keySet()) { - try { - allApps.add(new AutofillRecyclerAdapter.AppInfo(key, key, true, pm.getApplicationIcon("com.android.browser"))); - } catch (PackageManager.NameNotFoundException e) { - allApps.add(new AutofillRecyclerAdapter.AppInfo(key, key, true, null)); - } - } - - recyclerAdapter = new AutofillRecyclerAdapter(allApps, pm, AutofillPreferenceActivity.this); - return null; - } - - @Override - protected void onPostExecute(Void aVoid) { - runOnUiThread(() -> { - findViewById(R.id.progress_bar).setVisibility(View.GONE); - recyclerView.setAdapter(recyclerAdapter); - Bundle extras = getIntent().getExtras(); - if (extras != null) { - recyclerView.scrollToPosition(recyclerAdapter.getPosition(extras.getString("appName"))); - } - }); - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.kt new file mode 100644 index 00000000..43f2f33f --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillPreferenceActivity.kt @@ -0,0 +1,159 @@ +package com.zeapo.pwdstore.autofill + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.AsyncTask +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.core.app.NavUtils +import androidx.core.app.TaskStackBuilder +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.zeapo.pwdstore.R +import java.lang.ref.WeakReference +import java.util.ArrayList + +class AutofillPreferenceActivity : AppCompatActivity() { + + internal var recyclerAdapter: AutofillRecyclerAdapter? = null // let fragment have access + private var recyclerView: RecyclerView? = null + private var pm: PackageManager? = null + + private var recreate: Boolean = false // flag for action on up press; origin autofill dialog? different act + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.autofill_recycler_view) + recyclerView = findViewById(R.id.autofill_recycler) + + val layoutManager = LinearLayoutManager(this) + recyclerView!!.layoutManager = layoutManager + recyclerView!!.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + + pm = packageManager + + PopulateTask(this).execute() + + // if the preference activity was started from the autofill dialog + recreate = false + val extras = intent.extras + if (extras != null) { + recreate = true + + showDialog(extras.getString("packageName"), extras.getString("appName"), extras.getBoolean("isWeb")) + } + + title = "Autofill Apps" + + val fab = findViewById(R.id.fab) + fab.setOnClickListener { showDialog("", "", true) } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.autofill_preference, menu) + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(s: String): Boolean { + return false + } + + override fun onQueryTextChange(s: String): Boolean { + if (recyclerAdapter != null) { + recyclerAdapter!!.filter(s) + } + return true + } + }) + + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // in service, we CLEAR_TASK. then we set the recreate flag. + // something of a hack, but w/o CLEAR_TASK, behaviour was unpredictable + if (item.itemId == android.R.id.home) { + val upIntent = NavUtils.getParentActivityIntent(this) + if (recreate) { + TaskStackBuilder.create(this) + .addNextIntentWithParentStack(upIntent!!) + .startActivities() + } else { + NavUtils.navigateUpTo(this, upIntent!!) + } + return true + } + return super.onOptionsItemSelected(item) + } + + fun showDialog(packageName: String?, appName: String?, isWeb: Boolean) { + val df = AutofillFragment() + val args = Bundle() + args.putString("packageName", packageName) + args.putString("appName", appName) + args.putBoolean("isWeb", isWeb) + df.arguments = args + df.show(supportFragmentManager, "autofill_dialog") + } + + companion object { + private class PopulateTask(activity: AutofillPreferenceActivity) : AsyncTask() { + + val weakReference = WeakReference(activity) + + override fun onPreExecute() { + weakReference.get()?.apply { + runOnUiThread { findViewById(R.id.progress_bar).visibility = View.VISIBLE } + } + } + + override fun doInBackground(vararg params: Void): Void? { + val pm = weakReference.get()?.pm ?: return null + val intent = Intent(Intent.ACTION_MAIN) + intent.addCategory(Intent.CATEGORY_LAUNCHER) + val allAppsResolveInfo = pm.queryIntentActivities(intent, 0) + val allApps = ArrayList() + + for (app in allAppsResolveInfo) { + allApps.add(AutofillRecyclerAdapter.AppInfo(app.activityInfo.packageName, app.loadLabel(pm).toString(), false, app.loadIcon(pm))) + } + + val prefs = weakReference.get()?.getSharedPreferences("autofill_web", Context.MODE_PRIVATE) + val prefsMap = prefs!!.all + for (key in prefsMap.keys) { + try { + allApps.add(AutofillRecyclerAdapter.AppInfo(key, key, true, pm.getApplicationIcon("com.android.browser"))) + } catch (e: PackageManager.NameNotFoundException) { + allApps.add(AutofillRecyclerAdapter.AppInfo(key, key, true, null)) + } + + } + weakReference.get()?.recyclerAdapter = AutofillRecyclerAdapter(allApps, weakReference.get()!!) + return null + } + + override fun onPostExecute(ignored: Void?) { + weakReference.get()?.apply { + runOnUiThread { + findViewById(R.id.progress_bar).visibility = View.GONE + recyclerView!!.adapter = recyclerAdapter + val extras = intent.extras + if (extras != null) { + recyclerView!!.scrollToPosition(recyclerAdapter!!.getPosition(extras.getString("appName")!!)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.java b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.java deleted file mode 100644 index bed2aa7e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.java +++ /dev/null @@ -1,192 +0,0 @@ -package com.zeapo.pwdstore.autofill; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.graphics.drawable.Drawable; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SortedList; -import androidx.recyclerview.widget.SortedListAdapterCallback; -import com.zeapo.pwdstore.R; - -import java.util.ArrayList; -import java.util.List; - -class AutofillRecyclerAdapter extends RecyclerView.Adapter { - - private SortedList apps; - private ArrayList allApps; // for filtering, maintain a list of all - private AutofillPreferenceActivity activity; - private Drawable browserIcon = null; - - AutofillRecyclerAdapter(List allApps, final PackageManager pm - , AutofillPreferenceActivity activity) { - SortedList.Callback callback = new SortedListAdapterCallback(this) { - // don't take into account secondary text. This is good enough - // for the limited add/remove usage for websites - @Override - public int compare(AppInfo o1, AppInfo o2) { - return o1.appName.toLowerCase().compareTo(o2.appName.toLowerCase()); - } - - @Override - public boolean areContentsTheSame(AppInfo oldItem, AppInfo newItem) { - return oldItem.appName.equals(newItem.appName); - } - - @Override - public boolean areItemsTheSame(AppInfo item1, AppInfo item2) { - return item1.appName.equals(item2.appName); - } - }; - this.apps = new SortedList<>(AppInfo.class, callback); - this.apps.addAll(allApps); - this.allApps = new ArrayList<>(allApps); - this.activity = activity; - try { - browserIcon = activity.getPackageManager().getApplicationIcon("com.android.browser"); - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } - } - - @Override - public AutofillRecyclerAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.autofill_row_layout, parent, false); - return new ViewHolder(v); - } - - @Override - public void onBindViewHolder(AutofillRecyclerAdapter.ViewHolder holder, int position) { - AppInfo app = apps.get(position); - holder.packageName = app.packageName; - holder.appName = app.appName; - holder.isWeb = app.isWeb; - - holder.icon.setImageDrawable(app.icon); - holder.name.setText(app.appName); - - holder.secondary.setVisibility(View.VISIBLE); - holder.view.setBackgroundResource(R.color.grey_white_1000); - - SharedPreferences prefs; - if (!app.appName.equals(app.packageName)) { - prefs = activity.getApplicationContext().getSharedPreferences("autofill", Context.MODE_PRIVATE); - } else { - prefs = activity.getApplicationContext().getSharedPreferences("autofill_web", Context.MODE_PRIVATE); - } - String preference = prefs.getString(holder.packageName, ""); - switch (preference) { - case "": - holder.secondary.setVisibility(View.GONE); - holder.view.setBackgroundResource(0); - break; - case "/first": - holder.secondary.setText(R.string.autofill_apps_first); - break; - case "/never": - holder.secondary.setText(R.string.autofill_apps_never); - break; - default: - holder.secondary.setText(R.string.autofill_apps_match); - holder.secondary.append(" " + preference.split("\n")[0]); - if ((preference.trim().split("\n").length - 1) > 0) { - holder.secondary.append(" and " - + (preference.trim().split("\n").length - 1) + " more"); - } - break; - } - } - - @Override - public int getItemCount() { - return apps.size(); - } - - int getPosition(String appName) { - return apps.indexOf(new AppInfo(null, appName, false, null)); - } - - // for websites, URL = packageName == appName - void addWebsite(String packageName) { - apps.add(new AppInfo(packageName, packageName, true, browserIcon)); - allApps.add(new AppInfo(packageName, packageName, true, browserIcon)); - } - - void removeWebsite(String packageName) { - apps.remove(new AppInfo(null, packageName, false, null)); - allApps.remove(new AppInfo(null, packageName, false, null)); // compare with equals - } - - void updateWebsite(String oldPackageName, String packageName) { - apps.updateItemAt(getPosition(oldPackageName), new AppInfo(packageName, packageName, true, browserIcon)); - allApps.remove(new AppInfo(null, oldPackageName, false, null)); // compare with equals - allApps.add(new AppInfo(null, packageName, false, null)); - } - - void filter(String s) { - if (s.isEmpty()) { - apps.addAll(allApps); - return; - } - apps.beginBatchedUpdates(); - for (AppInfo app : allApps) { - if (app.appName.toLowerCase().contains(s.toLowerCase())) { - apps.add(app); - } else { - apps.remove(app); - } - } - apps.endBatchedUpdates(); - } - - static class AppInfo { - public Drawable icon; - String packageName; - String appName; - boolean isWeb; - - AppInfo(String packageName, String appName, boolean isWeb, Drawable icon) { - this.packageName = packageName; - this.appName = appName; - this.isWeb = isWeb; - this.icon = icon; - } - - @Override - public boolean equals(Object o) { - return o instanceof AppInfo && this.appName.equals(((AppInfo) o).appName); - } - } - - class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { - public View view; - public TextView name; - public ImageView icon; - TextView secondary; - String packageName; - String appName; - Boolean isWeb; - - ViewHolder(View view) { - super(view); - this.view = view; - name = view.findViewById(R.id.app_name); - secondary = view.findViewById(R.id.secondary_text); - icon = view.findViewById(R.id.app_icon); - view.setOnClickListener(this); - } - - @Override - public void onClick(View v) { - activity.showDialog(packageName, appName, isWeb); - } - - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.kt new file mode 100644 index 00000000..f4c9357b --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillRecyclerAdapter.kt @@ -0,0 +1,170 @@ +package com.zeapo.pwdstore.autofill + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SortedList +import androidx.recyclerview.widget.SortedListAdapterCallback +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.splitLines +import java.util.ArrayList +import java.util.Locale + +internal class AutofillRecyclerAdapter( + allApps: List, + private val activity: AutofillPreferenceActivity +) : RecyclerView.Adapter() { + + private val apps: SortedList + private val allApps: ArrayList // for filtering, maintain a list of all + private var browserIcon: Drawable? = null + + init { + val callback = object : SortedListAdapterCallback(this) { + // don't take into account secondary text. This is good enough + // for the limited add/remove usage for websites + override fun compare(o1: AppInfo, o2: AppInfo): Int { + return o1.appName.toLowerCase(Locale.ROOT).compareTo(o2.appName.toLowerCase(Locale.ROOT)) + } + + override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo): Boolean { + return oldItem.appName == newItem.appName + } + + override fun areItemsTheSame(item1: AppInfo, item2: AppInfo): Boolean { + return item1.appName == item2.appName + } + } + apps = SortedList(AppInfo::class.java, callback) + apps.addAll(allApps) + this.allApps = ArrayList(allApps) + try { + browserIcon = activity.packageManager.getApplicationIcon("com.android.browser") + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.autofill_row_layout, parent, false) + return ViewHolder(v) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val app = apps.get(position) + holder.packageName = app.packageName + holder.appName = app.appName + holder.isWeb = app.isWeb + + holder.icon.setImageDrawable(app.icon) + holder.name.text = app.appName + + holder.secondary.visibility = View.VISIBLE + holder.view.setBackgroundResource(R.color.grey_white_1000) + + val prefs: SharedPreferences + prefs = if (app.appName != app.packageName) { + activity.applicationContext.getSharedPreferences("autofill", Context.MODE_PRIVATE) + } else { + activity.applicationContext.getSharedPreferences("autofill_web", Context.MODE_PRIVATE) + } + when (val preference = prefs.getString(holder.packageName, "")) { + "" -> { + holder.secondary.visibility = View.GONE + holder.view.setBackgroundResource(0) + } + "/first" -> holder.secondary.setText(R.string.autofill_apps_first) + "/never" -> holder.secondary.setText(R.string.autofill_apps_never) + else -> { + holder.secondary.setText(R.string.autofill_apps_match) + holder.secondary.append(" " + preference!!.splitLines()[0]) + if (preference.trim { it <= ' ' }.splitLines().size - 1 > 0) { + holder.secondary.append(" and " + + (preference.trim { it <= ' ' }.splitLines().size - 1) + " more") + } + } + } + } + + override fun getItemCount(): Int { + return apps.size() + } + + fun getPosition(appName: String): Int { + return apps.indexOf(AppInfo(null, appName, false, null)) + } + + // for websites, URL = packageName == appName + fun addWebsite(packageName: String) { + apps.add(AppInfo(packageName, packageName, true, browserIcon)) + allApps.add(AppInfo(packageName, packageName, true, browserIcon)) + } + + fun removeWebsite(packageName: String) { + apps.remove(AppInfo(null, packageName, false, null)) + allApps.remove(AppInfo(null, packageName, false, null)) // compare with equals + } + + fun updateWebsite(oldPackageName: String, packageName: String) { + apps.updateItemAt(getPosition(oldPackageName), AppInfo(packageName, packageName, true, browserIcon)) + allApps.remove(AppInfo(null, oldPackageName, false, null)) // compare with equals + allApps.add(AppInfo(null, packageName, false, null)) + } + + fun filter(s: String) { + if (s.isEmpty()) { + apps.addAll(allApps) + return + } + apps.beginBatchedUpdates() + for (app in allApps) { + if (app.appName.toLowerCase(Locale.ROOT).contains(s.toLowerCase(Locale.ROOT))) { + apps.add(app) + } else { + apps.remove(app) + } + } + apps.endBatchedUpdates() + } + + internal class AppInfo(var packageName: String?, var appName: String, var isWeb: Boolean, var icon: Drawable?) { + + override fun equals(other: Any?): Boolean { + return other is AppInfo && this.appName == other.appName + } + + override fun hashCode(): Int { + var result = packageName?.hashCode() ?: 0 + result = 31 * result + appName.hashCode() + result = 31 * result + isWeb.hashCode() + result = 31 * result + (icon?.hashCode() ?: 0) + return result + } + } + + internal inner class ViewHolder(var view: View) : RecyclerView.ViewHolder(view), View.OnClickListener { + var name: TextView = view.findViewById(R.id.app_name) + var icon: ImageView = view.findViewById(R.id.app_icon) + var secondary: TextView = view.findViewById(R.id.secondary_text) + var packageName: String? = null + var appName: String? = null + var isWeb: Boolean = false + + init { + view.setOnClickListener(this) + } + + override fun onClick(v: View) { + activity.showDialog(packageName, appName, isWeb) + } + + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.java b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.java deleted file mode 100644 index 8ad27b4f..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.java +++ /dev/null @@ -1,606 +0,0 @@ -package com.zeapo.pwdstore.autofill; - -import android.accessibilityservice.AccessibilityService; -import android.app.PendingIntent; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.util.Log; -import android.view.WindowManager; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityWindowInfo; -import android.widget.Toast; -import androidx.appcompat.app.AlertDialog; -import com.zeapo.pwdstore.PasswordEntry; -import com.zeapo.pwdstore.R; -import com.zeapo.pwdstore.utils.PasswordRepository; -import org.apache.commons.io.FileUtils; -import org.openintents.openpgp.IOpenPgpService2; -import org.openintents.openpgp.OpenPgpError; -import org.openintents.openpgp.util.OpenPgpApi; -import org.openintents.openpgp.util.OpenPgpServiceConnection; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -public class AutofillService extends AccessibilityService { - private static AutofillService instance; - private OpenPgpServiceConnection serviceConnection; - private SharedPreferences settings; - private AccessibilityNodeInfo info; // the original source of the event (the edittext field) - private ArrayList items; // password choices - private int lastWhichItem; - private AlertDialog dialog; - private AccessibilityWindowInfo window; - private Intent resultData = null; // need the intent which contains results from user interaction - private CharSequence packageName; - private boolean ignoreActionFocus = false; - private String webViewTitle = null; - private String webViewURL = null; - private PasswordEntry lastPassword; - private long lastPasswordMaxDate; - - public static AutofillService getInstance() { - return instance; - } - - public void setResultData(Intent data) { - resultData = data; - } - - public void setPickedPassword(String path) { - items.add(new File(PasswordRepository.getRepositoryDirectory(getApplicationContext()) + "/" + path + ".gpg")); - bindDecryptAndVerify(); - } - - @Override - public void onCreate() { - super.onCreate(); - instance = this; - } - - @Override - protected void onServiceConnected() { - super.onServiceConnected(); - serviceConnection = new OpenPgpServiceConnection(AutofillService.this - , "org.sufficientlysecure.keychain"); - serviceConnection.bindToService(); - settings = PreferenceManager.getDefaultSharedPreferences(this); - } - - @Override - public void onAccessibilityEvent(AccessibilityEvent event) { - // remove stored password from cache - if (lastPassword != null && System.currentTimeMillis() > lastPasswordMaxDate) { - lastPassword = null; - } - - // if returning to the source app from a successful AutofillActivity - if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED - && event.getPackageName() != null && event.getPackageName().equals(packageName) - && resultData != null) { - bindDecryptAndVerify(); - } - - // look for webView and trigger accessibility events if window changes - // or if page changes in chrome - if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED - || (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED - && event.getPackageName() != null - && (event.getPackageName().equals("com.android.chrome") - || event.getPackageName().equals("com.android.browser")))) { - // there is a chance for getRootInActiveWindow() to return null at any time. save it. - try { - AccessibilityNodeInfo root = getRootInActiveWindow(); - webViewTitle = searchWebView(root); - webViewURL = null; - if (webViewTitle != null) { - List nodes = root.findAccessibilityNodeInfosByViewId("com.android.chrome:id/url_bar"); - if (nodes.isEmpty()) { - nodes = root.findAccessibilityNodeInfosByViewId("com.android.browser:id/url"); - } - for (AccessibilityNodeInfo node : nodes) - if (node.getText() != null) { - try { - webViewURL = new URL(node.getText().toString()).getHost(); - } catch (MalformedURLException e) { - if (e.toString().contains("Protocol not found")) { - try { - webViewURL = new URL("http://" + node.getText().toString()).getHost(); - } catch (MalformedURLException ignored) { - } - } - } - } - } - } catch (Exception e) { - // sadly we were unable to access the data we wanted - return; - } - } - - // nothing to do if field is keychain app or system ui - if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED - || event.getPackageName() != null && event.getPackageName().equals("org.sufficientlysecure.keychain") - || event.getPackageName() != null && event.getPackageName().equals("com.android.systemui")) { - dismissDialog(event); - return; - } - - if (!event.isPassword()) { - if (lastPassword != null && event.getEventType() == AccessibilityEvent.TYPE_VIEW_FOCUSED && event.getSource().isEditable()) { - showPasteUsernameDialog(event.getSource(), lastPassword); - return; - } else { - // nothing to do if not password field focus - dismissDialog(event); - return; - } - } - - if (dialog != null && dialog.isShowing()) { - // the current dialog must belong to this window; ignore clicks on this password field - // why handle clicks at all then? some cases e.g. Paypal there is no initial focus event - if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_CLICKED) { - return; - } - // if it was not a click, the field was refocused or another field was focused; recreate - dialog.dismiss(); - dialog = null; - } - - // ignore the ACTION_FOCUS from decryptAndVerify otherwise dialog will appear after Fill - if (ignoreActionFocus) { - ignoreActionFocus = false; - return; - } - - // need to request permission before attempting to draw dialog - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - && !Settings.canDrawOverlays(this)) { - Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:" + getPackageName())); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - return; - } - - // we are now going to attempt to fill, save AccessibilityNodeInfo for later in decryptAndVerify - // (there should be a proper way to do this, although this seems to work 90% of the time) - info = event.getSource(); - if (info == null) return; - - // save the dialog's corresponding window so we can use getWindows() in dismissDialog - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - window = info.getWindow(); - } - - String packageName; - String appName; - boolean isWeb; - - // Match with the app if a webview was not found or one was found but - // there's no title or url to go by - if (webViewTitle == null || (webViewTitle.equals("") && webViewURL == null)) { - if (info.getPackageName() == null) return; - packageName = info.getPackageName().toString(); - - // get the app name and find a corresponding password - PackageManager packageManager = getPackageManager(); - ApplicationInfo applicationInfo; - try { - applicationInfo = packageManager.getApplicationInfo(event.getPackageName().toString(), 0); - } catch (PackageManager.NameNotFoundException e) { - applicationInfo = null; - } - appName = (applicationInfo != null ? packageManager.getApplicationLabel(applicationInfo) : "").toString(); - - isWeb = false; - - setAppMatchingPasswords(appName, packageName); - } else { - // now we may have found a title but webViewURL could be null - // we set packagename so that we can find the website setting entry - packageName = setWebMatchingPasswords(webViewTitle, webViewURL); - appName = packageName; - isWeb = true; - } - - // if autofill_always checked, show dialog even if no matches (automatic - // or otherwise) - if (items.isEmpty() && !settings.getBoolean("autofill_always", false)) { - return; - } - showSelectPasswordDialog(packageName, appName, isWeb); - } - - private String searchWebView(AccessibilityNodeInfo source) { - return searchWebView(source, 10); - } - - private String searchWebView(AccessibilityNodeInfo source, int depth) { - if (source == null || depth == 0) { - return null; - } - for (int i = 0; i < source.getChildCount(); i++) { - AccessibilityNodeInfo u = source.getChild(i); - if (u == null) { - continue; - } - if (u.getClassName() != null && u.getClassName().equals("android.webkit.WebView")) { - if (u.getContentDescription() != null) { - return u.getContentDescription().toString(); - } - return ""; - } - String webView = searchWebView(u, depth - 1); - if (webView != null) { - return webView; - } - u.recycle(); - } - return null; - } - - // dismiss the dialog if the window has changed - private void dismissDialog(AccessibilityEvent event) { - // the default keyboard showing/hiding is a window state changed event - // on Android 5+ we can use getWindows() to determine when the original window is not visible - // on Android 4.3 we have to use window state changed events and filter out the keyboard ones - // there may be other exceptions... - boolean dismiss; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - dismiss = !getWindows().contains(window); - } else { - dismiss = !(event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && - event.getPackageName() != null && - event.getPackageName().toString().contains("inputmethod")); - } - if (dismiss && dialog != null && dialog.isShowing()) { - dialog.dismiss(); - dialog = null; - } - } - - private String setWebMatchingPasswords(String webViewTitle, String webViewURL) { - // Return the URL needed to open the corresponding Settings. - String settingsURL = webViewURL; - - // if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never" - String defValue = settings.getBoolean("autofill_default", true) ? "/first" : "/never"; - SharedPreferences prefs; - String preference; - - prefs = getSharedPreferences("autofill_web", Context.MODE_PRIVATE); - preference = defValue; - if (webViewURL != null) { - final String webViewUrlLowerCase = webViewURL.toLowerCase(); - Map prefsMap = prefs.getAll(); - for (String key : prefsMap.keySet()) { - // for websites unlike apps there can be blank preference of "" which - // means use default, so ignore it. - final String value = prefs.getString(key, null); - final String keyLowerCase = key.toLowerCase(); - if (value != null && !value.equals("") - && (webViewUrlLowerCase.contains(keyLowerCase) || keyLowerCase.contains(webViewUrlLowerCase))) { - preference = value; - settingsURL = key; - } - } - } - - switch (preference) { - case "/first": - if (!PasswordRepository.isInitialized()) { - PasswordRepository.initialize(this); - } - items = searchPasswords(PasswordRepository.getRepositoryDirectory(this), webViewTitle); - break; - case "/never": - items = new ArrayList<>(); - break; - default: - getPreferredPasswords(preference); - } - - return settingsURL; - } - - private void setAppMatchingPasswords(String appName, String packageName) { - // if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never" - String defValue = settings.getBoolean("autofill_default", true) ? "/first" : "/never"; - SharedPreferences prefs; - String preference; - - prefs = getSharedPreferences("autofill", Context.MODE_PRIVATE); - preference = prefs.getString(packageName, defValue); - - switch (preference) { - case "/first": - if (!PasswordRepository.isInitialized()) { - PasswordRepository.initialize(this); - } - items = searchPasswords(PasswordRepository.getRepositoryDirectory(this), appName); - break; - case "/never": - items = new ArrayList<>(); - break; - default: - getPreferredPasswords(preference); - } - } - - // Put the newline separated list of passwords from the SharedPreferences - // file into the items list. - private void getPreferredPasswords(String preference) { - if (!PasswordRepository.isInitialized()) { - PasswordRepository.initialize(this); - } - String preferredPasswords[] = preference.split("\n"); - items = new ArrayList<>(); - for (String password : preferredPasswords) { - String path = PasswordRepository.getRepositoryDirectory(getApplicationContext()) + "/" + password + ".gpg"; - if (new File(path).exists()) { - items.add(new File(path)); - } - } - } - - private ArrayList searchPasswords(File path, String appName) { - ArrayList passList = PasswordRepository.getFilesList(path); - - if (passList.size() == 0) return new ArrayList<>(); - - ArrayList items = new ArrayList<>(); - - for (File file : passList) { - if (file.isFile()) { - if (!file.isHidden() && appName.toLowerCase().contains(file.getName().toLowerCase().replace(".gpg", ""))) { - items.add(file); - } - } else { - if (!file.isHidden()) { - items.addAll(searchPasswords(file, appName)); - } - } - } - return items; - } - - private void showPasteUsernameDialog(final AccessibilityNodeInfo node, final PasswordEntry password) { - if (dialog != null) { - dialog.dismiss(); - dialog = null; - } - - AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.Theme_AppCompat_Dialog); - builder.setNegativeButton(R.string.dialog_cancel, (d, which) -> { - dialog.dismiss(); - dialog = null; - }); - builder.setPositiveButton(R.string.autofill_paste, (d, which) -> { - pasteText(node, password.getUsername()); - dialog.dismiss(); - dialog = null; - }); - builder.setMessage(getString(R.string.autofill_paste_username, password.getUsername())); - - dialog = builder.create(); - this.setDialogType(dialog); - dialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); - dialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - dialog.show(); - } - - private void showSelectPasswordDialog(final String packageName, final String appName, final boolean isWeb) { - if (dialog != null) { - dialog.dismiss(); - dialog = null; - } - - AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.Theme_AppCompat_Dialog); - builder.setNegativeButton(R.string.dialog_cancel, (d, which) -> { - dialog.dismiss(); - dialog = null; - }); - builder.setNeutralButton("Settings", (dialog, which) -> { - //TODO make icon? gear? - // the user will have to return to the app themselves. - Intent intent = new Intent(AutofillService.this, AutofillPreferenceActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra("packageName", packageName); - intent.putExtra("appName", appName); - intent.putExtra("isWeb", isWeb); - startActivity(intent); - }); - - // populate the dialog items, always with pick + pick and match. Could - // make it optional (or make height a setting for the same effect) - CharSequence itemNames[] = new CharSequence[items.size() + 2]; - for (int i = 0; i < items.size(); i++) { - itemNames[i] = items.get(i).getName().replace(".gpg", ""); - } - itemNames[items.size()] = getString(R.string.autofill_pick); - itemNames[items.size() + 1] = getString(R.string.autofill_pick_and_match); - builder.setItems(itemNames, (dialog, which) -> { - lastWhichItem = which; - if (which < items.size()) { - bindDecryptAndVerify(); - } else if (which == items.size()) { - Intent intent = new Intent(AutofillService.this, AutofillActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra("pick", true); - startActivity(intent); - } else { - lastWhichItem--; // will add one element to items, so lastWhichItem=items.size()+1 - Intent intent = new Intent(AutofillService.this, AutofillActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra("pickMatchWith", true); - intent.putExtra("packageName", packageName); - intent.putExtra("isWeb", isWeb); - startActivity(intent); - } - }); - - dialog = builder.create(); - this.setDialogType(dialog); - dialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); - dialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - // arbitrary non-annoying size - int height = 154; - if (itemNames.length > 1) { - height += 46; - } - dialog.getWindow().setLayout((int) (240 * getApplicationContext().getResources().getDisplayMetrics().density) - , (int) (height * getApplicationContext().getResources().getDisplayMetrics().density)); - dialog.show(); - } - - private void setDialogType(AlertDialog dialog) { - //noinspection ConstantConditions - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT); - } else { - dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY); - } - } - - @Override - public void onInterrupt() { - - } - - private void bindDecryptAndVerify() { - if (serviceConnection.getService() == null) { - // the service was disconnected, need to bind again - // give it a listener and in the callback we will decryptAndVerify - serviceConnection = new OpenPgpServiceConnection(AutofillService.this - , "org.sufficientlysecure.keychain", new onBoundListener()); - serviceConnection.bindToService(); - } else { - decryptAndVerify(); - } - } - - private void decryptAndVerify() { - packageName = info.getPackageName(); - Intent data; - if (resultData == null) { - data = new Intent(); - data.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY); - } else { - data = resultData; - resultData = null; - } - InputStream is = null; - try { - is = FileUtils.openInputStream(items.get(lastWhichItem)); - } catch (IOException e) { - e.printStackTrace(); - } - ByteArrayOutputStream os = new ByteArrayOutputStream(); - - OpenPgpApi api = new OpenPgpApi(AutofillService.this, serviceConnection.getService()); - // TODO we are dropping frames, (did we before??) find out why and maybe make this async - Intent result = api.executeApi(data, is, os); - switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - case OpenPgpApi.RESULT_CODE_SUCCESS: { - try { - final PasswordEntry entry = new PasswordEntry(os); - pasteText(info, entry.getPassword()); - - // save password entry for pasting the username as well - if (entry.hasUsername()) { - lastPassword = entry; - final int ttl = Integer.parseInt(settings.getString("general_show_time", "45")); - Toast.makeText(this, getString(R.string.autofill_toast_username, ttl), Toast.LENGTH_LONG).show(); - lastPasswordMaxDate = System.currentTimeMillis() + ttl * 1000L; - } - } catch (UnsupportedEncodingException e) { - Log.e(Constants.TAG, "UnsupportedEncodingException", e); - } - break; - } - case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: { - Log.i("PgpHandler", "RESULT_CODE_USER_INTERACTION_REQUIRED"); - PendingIntent pi = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT); - // need to start a blank activity to call startIntentSenderForResult - Intent intent = new Intent(AutofillService.this, AutofillActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra("pending_intent", pi); - startActivity(intent); - break; - } - case OpenPgpApi.RESULT_CODE_ERROR: { - OpenPgpError error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR); - Toast.makeText(AutofillService.this, - "Error from OpenKeyChain : " + error.getMessage(), - Toast.LENGTH_LONG).show(); - Log.e(Constants.TAG, "onError getErrorId:" + error.getErrorId()); - Log.e(Constants.TAG, "onError getMessage:" + error.getMessage()); - break; - } - } - } - - private void pasteText(final AccessibilityNodeInfo node, final String text) { - // if the user focused on something else, take focus back - // but this will open another dialog...hack to ignore this - // & need to ensure performAction correct (i.e. what is info now?) - ignoreActionFocus = node.performAction(AccessibilityNodeInfo.ACTION_FOCUS); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - Bundle args = new Bundle(); - args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text); - node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args); - } else { - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("autofill_pm", text); - clipboard.setPrimaryClip(clip); - node.performAction(AccessibilityNodeInfo.ACTION_PASTE); - - clip = ClipData.newPlainText("autofill_pm", ""); - clipboard.setPrimaryClip(clip); - if (settings.getBoolean("clear_clipboard_20x", false)) { - for (int i = 0; i < 20; i++) { - clip = ClipData.newPlainText(String.valueOf(i), String.valueOf(i)); - clipboard.setPrimaryClip(clip); - } - } - } - node.recycle(); - } - - final class Constants { - static final String TAG = "Keychain"; - } - - private class onBoundListener implements OpenPgpServiceConnection.OnBound { - @Override - public void onBound(IOpenPgpService2 service) { - decryptAndVerify(); - } - - @Override - public void onError(Exception e) { - e.printStackTrace(); - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt new file mode 100644 index 00000000..6cb3c678 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt @@ -0,0 +1,582 @@ +package com.zeapo.pwdstore.autofill + +import android.accessibilityservice.AccessibilityService +import android.app.PendingIntent +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.preference.PreferenceManager +import android.provider.Settings +import android.util.Log +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import com.zeapo.pwdstore.PasswordEntry +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.splitLines +import org.apache.commons.io.FileUtils +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError +import org.openintents.openpgp.util.OpenPgpApi +import org.openintents.openpgp.util.OpenPgpServiceConnection + +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.UnsupportedEncodingException +import java.net.MalformedURLException +import java.net.URL +import java.util.ArrayList +import java.util.Locale + +class AutofillService : AccessibilityService() { + private var serviceConnection: OpenPgpServiceConnection? = null + private var settings: SharedPreferences? = null + private var info: AccessibilityNodeInfo? = null // the original source of the event (the edittext field) + private var items: ArrayList = arrayListOf() // password choices + private var lastWhichItem: Int = 0 + private var dialog: AlertDialog? = null + private var window: AccessibilityWindowInfo? = null + private var resultData: Intent? = null // need the intent which contains results from user interaction + private var packageName: CharSequence? = null + private var ignoreActionFocus = false + private var webViewTitle: String? = null + private var webViewURL: String? = null + private var lastPassword: PasswordEntry? = null + private var lastPasswordMaxDate: Long = 0 + + fun setResultData(data: Intent) { + resultData = data + } + + fun setPickedPassword(path: String) { + items.add(File("${PasswordRepository.getRepositoryDirectory(applicationContext)}/$path.gpg")) + bindDecryptAndVerify() + } + + override fun onCreate() { + super.onCreate() + instance = this + } + + override fun onServiceConnected() { + super.onServiceConnected() + serviceConnection = OpenPgpServiceConnection(this@AutofillService, "org.sufficientlysecure.keychain") + serviceConnection!!.bindToService() + settings = PreferenceManager.getDefaultSharedPreferences(this) + } + + override fun onAccessibilityEvent(event: AccessibilityEvent) { + // remove stored password from cache + if (lastPassword != null && System.currentTimeMillis() > lastPasswordMaxDate) { + lastPassword = null + } + + // if returning to the source app from a successful AutofillActivity + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + && event.packageName != null && event.packageName == packageName + && resultData != null) { + bindDecryptAndVerify() + } + + // look for webView and trigger accessibility events if window changes + // or if page changes in chrome + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED + && event.packageName != null + && (event.packageName == "com.android.chrome" || event.packageName == "com.android.browser"))) { + // there is a chance for getRootInActiveWindow() to return null at any time. save it. + try { + val root = rootInActiveWindow + webViewTitle = searchWebView(root) + webViewURL = null + if (webViewTitle != null) { + var nodes = root.findAccessibilityNodeInfosByViewId("com.android.chrome:id/url_bar") + if (nodes.isEmpty()) { + nodes = root.findAccessibilityNodeInfosByViewId("com.android.browser:id/url") + } + for (node in nodes) + if (node.text != null) { + try { + webViewURL = URL(node.text.toString()).host + } catch (e: MalformedURLException) { + if (e.toString().contains("Protocol not found")) { + try { + webViewURL = URL("http://" + node.text.toString()).host + } catch (ignored: MalformedURLException) { + } + + } + } + + } + } + } catch (e: Exception) { + // sadly we were unable to access the data we wanted + return + } + + } + + // nothing to do if field is keychain app or system ui + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED + || event.packageName != null && event.packageName == "org.sufficientlysecure.keychain" + || event.packageName != null && event.packageName == "com.android.systemui") { + dismissDialog(event) + return + } + + if (!event.isPassword) { + if (lastPassword != null && event.eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED && event.source.isEditable) { + showPasteUsernameDialog(event.source, lastPassword!!) + return + } else { + // nothing to do if not password field focus + dismissDialog(event) + return + } + } + + if (dialog != null && dialog!!.isShowing) { + // the current dialog must belong to this window; ignore clicks on this password field + // why handle clicks at all then? some cases e.g. Paypal there is no initial focus event + if (event.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED) { + return + } + // if it was not a click, the field was refocused or another field was focused; recreate + dialog!!.dismiss() + dialog = null + } + + // ignore the ACTION_FOCUS from decryptAndVerify otherwise dialog will appear after Fill + if (ignoreActionFocus) { + ignoreActionFocus = false + return + } + + // need to request permission before attempting to draw dialog + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getPackageName())) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + return + } + + // we are now going to attempt to fill, save AccessibilityNodeInfo for later in decryptAndVerify + // (there should be a proper way to do this, although this seems to work 90% of the time) + info = event.source + if (info == null) return + + // save the dialog's corresponding window so we can use getWindows() in dismissDialog + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window = info!!.window + } + + val packageName: String + val appName: String + val isWeb: Boolean + + // Match with the app if a webview was not found or one was found but + // there's no title or url to go by + if (webViewTitle == null || webViewTitle == "" && webViewURL == null) { + if (info!!.packageName == null) return + packageName = info!!.packageName.toString() + + // get the app name and find a corresponding password + val packageManager = packageManager + var applicationInfo: ApplicationInfo? + try { + applicationInfo = packageManager.getApplicationInfo(event.packageName.toString(), 0) + } catch (e: PackageManager.NameNotFoundException) { + applicationInfo = null + } + + appName = (if (applicationInfo != null) packageManager.getApplicationLabel(applicationInfo) else "").toString() + + isWeb = false + + setAppMatchingPasswords(appName, packageName) + } else { + // now we may have found a title but webViewURL could be null + // we set packagename so that we can find the website setting entry + packageName = setWebMatchingPasswords(webViewTitle!!, webViewURL) + appName = packageName + isWeb = true + } + + // if autofill_always checked, show dialog even if no matches (automatic + // or otherwise) + if (items.isEmpty() && !settings!!.getBoolean("autofill_always", false)) { + return + } + showSelectPasswordDialog(packageName, appName, isWeb) + } + + private fun searchWebView(source: AccessibilityNodeInfo?, depth: Int = 10): String? { + if (source == null || depth == 0) { + return null + } + for (i in 0 until source.childCount) { + val u = source.getChild(i) ?: continue + if (u.className != null && u.className == "android.webkit.WebView") { + return if (u.contentDescription != null) { + u.contentDescription.toString() + } else "" + } + val webView = searchWebView(u, depth - 1) + if (webView != null) { + return webView + } + u.recycle() + } + return null + } + + // dismiss the dialog if the window has changed + private fun dismissDialog(event: AccessibilityEvent) { + // the default keyboard showing/hiding is a window state changed event + // on Android 5+ we can use getWindows() to determine when the original window is not visible + // on Android 4.3 we have to use window state changed events and filter out the keyboard ones + // there may be other exceptions... + val dismiss: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + !windows.contains(window) + } else { + !(event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && + event.packageName != null && + event.packageName.toString().contains("inputmethod")) + } + if (dismiss && dialog != null && dialog!!.isShowing) { + dialog!!.dismiss() + dialog = null + } + } + + private fun setWebMatchingPasswords(webViewTitle: String, webViewURL: String?): String { + // Return the URL needed to open the corresponding Settings. + var settingsURL = webViewURL + + // if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never" + val defValue = if (settings!!.getBoolean("autofill_default", true)) "/first" else "/never" + val prefs: SharedPreferences = getSharedPreferences("autofill_web", Context.MODE_PRIVATE) + var preference: String + + preference = defValue + if (webViewURL != null) { + val webViewUrlLowerCase = webViewURL.toLowerCase(Locale.ROOT) + val prefsMap = prefs.all + for (key in prefsMap.keys) { + // for websites unlike apps there can be blank preference of "" which + // means use default, so ignore it. + val value = prefs.getString(key, null) + val keyLowerCase = key.toLowerCase(Locale.ROOT) + if (value != null && value != "" + && (webViewUrlLowerCase.contains(keyLowerCase) || keyLowerCase.contains(webViewUrlLowerCase))) { + preference = value + settingsURL = key + } + } + } + + when (preference) { + "/first" -> { + if (!PasswordRepository.isInitialized()) { + PasswordRepository.initialize(this) + } + items = searchPasswords(PasswordRepository.getRepositoryDirectory(this), webViewTitle) + } + "/never" -> items = ArrayList() + else -> getPreferredPasswords(preference) + } + + return settingsURL!! + } + + private fun setAppMatchingPasswords(appName: String, packageName: String) { + // if autofill_default is checked and prefs.getString DNE, 'Automatically match with password'/"first" otherwise "never" + val defValue = if (settings!!.getBoolean("autofill_default", true)) "/first" else "/never" + val prefs: SharedPreferences = getSharedPreferences("autofill", Context.MODE_PRIVATE) + val preference: String? + + preference = prefs.getString(packageName, defValue) + + when (preference) { + "/first" -> { + if (!PasswordRepository.isInitialized()) { + PasswordRepository.initialize(this) + } + items = searchPasswords(PasswordRepository.getRepositoryDirectory(this), appName) + } + "/never" -> items = ArrayList() + else -> getPreferredPasswords(preference) + } + } + + // Put the newline separated list of passwords from the SharedPreferences + // file into the items list. + private fun getPreferredPasswords(preference: String) { + if (!PasswordRepository.isInitialized()) { + PasswordRepository.initialize(this) + } + val preferredPasswords = preference.splitLines() + items = ArrayList() + for (password in preferredPasswords) { + val path = PasswordRepository.getRepositoryDirectory(applicationContext).toString() + "/" + password + ".gpg" + if (File(path).exists()) { + items.add(File(path)) + } + } + } + + private fun searchPasswords(path: File?, appName: String): ArrayList { + val passList = PasswordRepository.getFilesList(path) + + if (passList.size == 0) return ArrayList() + + val items = ArrayList() + + for (file in passList) { + if (file.isFile) { + if (!file.isHidden && appName.toLowerCase(Locale.ROOT).contains(file.name.toLowerCase(Locale.ROOT).replace(".gpg", ""))) { + items.add(file) + } + } else { + if (!file.isHidden) { + items.addAll(searchPasswords(file, appName)) + } + } + } + return items + } + + private fun showPasteUsernameDialog(node: AccessibilityNodeInfo, password: PasswordEntry) { + if (dialog != null) { + dialog!!.dismiss() + dialog = null + } + + val builder = AlertDialog.Builder(this, R.style.Theme_AppCompat_Dialog) + builder.setNegativeButton(R.string.dialog_cancel) { _, _ -> + dialog!!.dismiss() + dialog = null + } + builder.setPositiveButton(R.string.autofill_paste) { _, _ -> + pasteText(node, password.username) + dialog!!.dismiss() + dialog = null + } + builder.setMessage(getString(R.string.autofill_paste_username, password.username)) + + dialog = builder.create() + this.setDialogType(dialog) + dialog!!.window!!.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + dialog!!.window!!.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + dialog!!.show() + } + + private fun showSelectPasswordDialog(packageName: String, appName: String, isWeb: Boolean) { + if (dialog != null) { + dialog!!.dismiss() + dialog = null + } + + val builder = AlertDialog.Builder(this, R.style.Theme_AppCompat_Dialog) + builder.setNegativeButton(R.string.dialog_cancel) { _, _ -> + dialog!!.dismiss() + dialog = null + } + builder.setNeutralButton("Settings") { _, _ -> + //TODO make icon? gear? + // the user will have to return to the app themselves. + val intent = Intent(this@AutofillService, AutofillPreferenceActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.putExtra("packageName", packageName) + intent.putExtra("appName", appName) + intent.putExtra("isWeb", isWeb) + startActivity(intent) + } + + // populate the dialog items, always with pick + pick and match. Could + // make it optional (or make height a setting for the same effect) + val itemNames = arrayOfNulls(items.size + 2) + for (i in items.indices) { + itemNames[i] = items[i].name.replace(".gpg", "") + } + itemNames[items.size] = getString(R.string.autofill_pick) + itemNames[items.size + 1] = getString(R.string.autofill_pick_and_match) + builder.setItems(itemNames) { _, which -> + lastWhichItem = which + when { + which < items.size -> bindDecryptAndVerify() + which == items.size -> { + val intent = Intent(this@AutofillService, AutofillActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.putExtra("pick", true) + startActivity(intent) + } + else -> { + lastWhichItem-- // will add one element to items, so lastWhichItem=items.size()+1 + val intent = Intent(this@AutofillService, AutofillActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.putExtra("pickMatchWith", true) + intent.putExtra("packageName", packageName) + intent.putExtra("isWeb", isWeb) + startActivity(intent) + } + } + } + + dialog = builder.create() + setDialogType(dialog) + dialog?.window?.apply { + val height = 200 + val density = context.resources.displayMetrics.density + addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + // arbitrary non-annoying size + setLayout((240 * density).toInt(), (height * density).toInt()) + } + dialog?.show() + } + + private fun setDialogType(dialog: AlertDialog?) { + dialog?.window?.apply { + setType( + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + WindowManager.LayoutParams.TYPE_SYSTEM_ALERT + else + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + ) + } + } + + override fun onInterrupt() {} + + private fun bindDecryptAndVerify() { + if (serviceConnection!!.service == null) { + // the service was disconnected, need to bind again + // give it a listener and in the callback we will decryptAndVerify + serviceConnection = OpenPgpServiceConnection(this@AutofillService, "org.sufficientlysecure.keychain", OnBoundListener()) + serviceConnection!!.bindToService() + } else { + decryptAndVerify() + } + } + + private fun decryptAndVerify() { + packageName = info!!.packageName + val data: Intent + if (resultData == null) { + data = Intent() + data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY + } else { + data = resultData!! + resultData = null + } + var `is`: InputStream? = null + try { + `is` = FileUtils.openInputStream(items[lastWhichItem]) + } catch (e: IOException) { + e.printStackTrace() + } + + val os = ByteArrayOutputStream() + + val api = OpenPgpApi(this@AutofillService, serviceConnection!!.service) + // TODO we are dropping frames, (did we before??) find out why and maybe make this async + val result = api.executeApi(data, `is`, os) + when (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + try { + val entry = PasswordEntry(os) + pasteText(info!!, entry.password) + + // save password entry for pasting the username as well + if (entry.hasUsername()) { + lastPassword = entry + val ttl = Integer.parseInt(settings!!.getString("general_show_time", "45")!!) + Toast.makeText(this, getString(R.string.autofill_toast_username, ttl), Toast.LENGTH_LONG).show() + lastPasswordMaxDate = System.currentTimeMillis() + ttl * 1000L + } + } catch (e: UnsupportedEncodingException) { + Log.e(Constants.TAG, "UnsupportedEncodingException", e) + } + + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + Log.i("PgpHandler", "RESULT_CODE_USER_INTERACTION_REQUIRED") + val pi = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) + // need to start a blank activity to call startIntentSenderForResult + val intent = Intent(this@AutofillService, AutofillActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + intent.putExtra("pending_intent", pi) + startActivity(intent) + } + OpenPgpApi.RESULT_CODE_ERROR -> { + val error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + Toast.makeText(this@AutofillService, + "Error from OpenKeyChain : " + error.message, + Toast.LENGTH_LONG).show() + Log.e(Constants.TAG, "onError getErrorId:" + error.errorId) + Log.e(Constants.TAG, "onError getMessage:" + error.message) + } + } + } + + private fun pasteText(node: AccessibilityNodeInfo, text: String?) { + // if the user focused on something else, take focus back + // but this will open another dialog...hack to ignore this + // & need to ensure performAction correct (i.e. what is info now?) + ignoreActionFocus = node.performAction(AccessibilityNodeInfo.ACTION_FOCUS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val args = Bundle() + args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text) + node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + } else { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + var clip = ClipData.newPlainText("autofill_pm", text) + clipboard.primaryClip = clip + node.performAction(AccessibilityNodeInfo.ACTION_PASTE) + + clip = ClipData.newPlainText("autofill_pm", "") + clipboard.primaryClip = clip + if (settings!!.getBoolean("clear_clipboard_20x", false)) { + for (i in 0..19) { + clip = ClipData.newPlainText(i.toString(), i.toString()) + clipboard.primaryClip = clip + } + } + } + node.recycle() + } + + internal object Constants { + const val TAG = "Keychain" + } + + private inner class OnBoundListener : OpenPgpServiceConnection.OnBound { + override fun onBound(service: IOpenPgpService2) { + decryptAndVerify() + } + + override fun onError(e: Exception) { + e.printStackTrace() + } + } + + companion object { + var instance: AutofillService? = null + private set + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt index f6f0b9a8..7d782a05 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt @@ -435,8 +435,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { val path = if (intent.getBooleanExtra("fromDecrypt", false)) fullPath else "$fullPath/$editName.gpg" api?.executeApiAsync(data, iStream, oStream) { result: Intent? -> - when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { + when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) { + RESULT_CODE_SUCCESS -> { try { // TODO This might fail, we should check that the write is successful val outputStream = FileUtils.openOutputStream(File(path)) @@ -459,7 +459,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { Log.e(TAG, "An Exception occurred", e) } } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + RESULT_CODE_ERROR -> handleError(result) } } @@ -516,7 +516,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { private fun calculateHotp(entry: PasswordEntry) { copyOtpToClipBoard(Otp.calculateCode(entry.hotpSecret, entry.hotpCounter!! + 1, "sha1", entry.digits)) - crypto_otp_show.text = Otp.calculateCode(entry.hotpSecret, entry.hotpCounter!! + 1, "sha1", entry.digits) + crypto_otp_show.text = Otp.calculateCode(entry.hotpSecret, entry.hotpCounter + 1, "sha1", entry.digits) crypto_extra_show.text = entry.extraContent } @@ -539,8 +539,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { val data = receivedIntent ?: Intent() data.action = OpenPgpApi.ACTION_GET_KEY_IDS api?.executeApiAsync(data, null, null) { result: Intent? -> - when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { + when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) { + RESULT_CODE_SUCCESS -> { try { val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) val keys = ids.map { it.toString() }.toSet() @@ -557,7 +557,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { } } RESULT_CODE_USER_INTERACTION_REQUIRED -> handleUserInteractionRequest(result, REQUEST_KEY_ID) - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + RESULT_CODE_ERROR -> handleError(result) } } } @@ -580,23 +580,23 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { Log.d(TAG, "onActivityResult resultCode: $resultCode") if (data == null) { - setResult(AppCompatActivity.RESULT_CANCELED, null) + setResult(RESULT_CANCELED, null) finish() return } // try again after user interaction - if (resultCode == AppCompatActivity.RESULT_OK) { + if (resultCode == RESULT_OK) { when (requestCode) { REQUEST_DECRYPT -> decryptAndVerify(data) REQUEST_KEY_ID -> getKeyIds(data) else -> { - setResult(AppCompatActivity.RESULT_OK) + setResult(RESULT_OK) finish() } } - } else if (resultCode == AppCompatActivity.RESULT_CANCELED) { - setResult(AppCompatActivity.RESULT_CANCELED, data) + } else if (resultCode == RESULT_CANCELED) { + setResult(RESULT_CANCELED, data) finish() } } @@ -786,7 +786,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { if (crypto_password_show != null) { // clear password; if decrypt changed to encrypt layout via edit button, no need if (passwordEntry?.hotpIsIncremented() == false) { - setResult(AppCompatActivity.RESULT_CANCELED) + setResult(RESULT_CANCELED) } passwordEntry = null crypto_password_show.text = "" diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt new file mode 100644 index 00000000..a2be3ddc --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -0,0 +1,5 @@ +package com.zeapo.pwdstore.utils + +fun String.splitLines(): Array { + return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() +}