diff options
| author | Harsh Shandilya <msfjarvis@gmail.com> | 2019-05-29 00:42:09 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-05-29 00:42:09 +0530 |
| commit | 53b42905f17553b74160eaae7d5352fc7aa648d7 (patch) | |
| tree | 6df5e28bdcf4b10eabda3c15f89410114114862d /app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt | |
| parent | 321035c319979010cafb10ce8bd83890d0c0a195 (diff) | |
| download | HardenedPass-53b42905f17553b74160eaae7d5352fc7aa648d7.tar.gz HardenedPass-53b42905f17553b74160eaae7d5352fc7aa648d7.zip | |
Convert autofill package to Kotlin (#515)
Signed-off-by: Harsh Shandilya <msfjarvis@gmail.com>
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt')
| -rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt | 582 |
1 files changed, 582 insertions, 0 deletions
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<File> = 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<File> { + val passList = PasswordRepository.getFilesList(path) + + if (passList.size == 0) return ArrayList() + + val items = ArrayList<File>() + + 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<CharSequence>(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<PendingIntent>(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<OpenPgpError>(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 + } +} |
