Add support for Oreo Autofill (#653)

Adds support for the Autofill feature first available in Android Oreo.

In compatible apps and browsers, login forms are automatically detected and
the user is presented with options to fill or generate credentials. In most apps
and certain browsers, the service will also offer to create new Password Store
entries from credentials entered into login forms.

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Fabian Henneke 2020-03-24 14:03:40 +01:00 committed by GitHub
parent 98e9f6734a
commit bebe434683
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 3566 additions and 85 deletions

5
.github/CODEOWNERS vendored
View file

@ -1,2 +1,7 @@
# General rule for all code base
* @msfjarvis @zidhuss @Skrilltrax
# Oreo Autofill
app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ @FabianHenneke
*oreo_autofill* @FabianHenneke
scripts/hash_browser_app.sh @FabianHenneke

View file

@ -101,8 +101,11 @@ dependencies {
}
implementation deps.third_party.jsch
implementation deps.third_party.openpgp_ktx
implementation deps.third_party.publicsuffixlist
implementation deps.third_party.recyclical
implementation deps.third_party.ssh_auth
implementation deps.third_party.timber
implementation deps.third_party.timberkt
implementation deps.third_party.whatthestack
if (isSnapshot()) {

View file

@ -58,6 +58,13 @@
<service
android:name=".ClipboardService"
android:process=":clipboard_service_process" />
<service android:name=".autofill.oreo.OreoAutofillService"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
<meta-data android:name="android.autofill" android:resource="@xml/oreo_autofill_service" />
</service>
<activity
android:name=".autofill.AutofillActivity"
@ -74,6 +81,20 @@
android:windowSoftInputMode="adjustResize" />
<activity android:name=".SelectFolderActivity" />
<activity android:name=".sshkeygen.SshKeyGenActivity" android:windowSoftInputMode="adjustResize" />
<activity android:name=".autofill.oreo.ui.AutofillDecryptActivity" />
<activity
android:name=".autofill.oreo.ui.AutofillFilterView"
android:configChanges="orientation|keyboardHidden"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/DialogLikeTheme" />
<activity
android:name=".autofill.oreo.ui.AutofillSaveActivity"
android:theme="@style/DialogLikeTheme"/>
<activity
android:name=".autofill.oreo.ui.AutofillPublisherChangedActivity"
android:configChanges="orientation|keyboardHidden"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/DialogLikeTheme" />
</application>
</manifest>

View file

@ -20,7 +20,7 @@ class PasswordEntry(private val content: String) {
val totpAlgorithm: String
val hotpSecret: String?
val hotpCounter: Long?
var extraContent: String? = null
var extraContent: String
private set
private var isIncremented = false
@ -41,7 +41,7 @@ class PasswordEntry(private val content: String) {
}
fun hasExtraContent(): Boolean {
return !extraContent.isNullOrEmpty()
return extraContent.isNotEmpty()
}
fun hasUsername(): Boolean {
@ -63,19 +63,30 @@ class PasswordEntry(private val content: String) {
fun incrementHotp() {
content.split("\n".toRegex()).forEach { line ->
if (line.startsWith("otpauth://hotp/")) {
extraContent = extraContent?.replaceFirst("counter=[0-9]+".toRegex(), "counter=${hotpCounter!! + 1}")
extraContent = extraContent.replaceFirst("counter=[0-9]+".toRegex(), "counter=${hotpCounter!! + 1}")
isIncremented = true
}
}
}
private fun findUsername(): String? {
val extraLines = extraContent!!.split("\n".toRegex())
for (line in extraLines) {
for (field in USERNAME_FIELDS) {
if (line.toLowerCase().startsWith("$field:", ignoreCase = true)) {
return line.split(": *".toRegex(), 2).toTypedArray()[1]
val extraContentWithoutUsername by lazy {
var usernameFound = false
extraContent.splitToSequence("\n").filter { line ->
if (usernameFound)
return@filter true
if (USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) }) {
usernameFound = true
return@filter false
}
true
}.joinToString(separator = "\n")
}
private fun findUsername(): String? {
extraContent.splitToSequence("\n").forEach { line ->
for (prefix in USERNAME_FIELDS) {
if (line.startsWith(prefix, ignoreCase = true))
return line.substring(prefix.length).trimStart()
}
}
return null
@ -152,6 +163,6 @@ class PasswordEntry(private val content: String) {
companion object {
private val USERNAME_FIELDS = arrayOf("login", "username")
private val USERNAME_FIELDS = arrayOf("login:", "username:")
}
}

View file

@ -32,6 +32,7 @@ import androidx.fragment.app.FragmentManager
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.crypto.PgpActivity
import com.zeapo.pwdstore.crypto.PgpActivity.Companion.getLongName
import com.zeapo.pwdstore.git.GitActivity
@ -650,10 +651,21 @@ class PasswordStore : AppCompatActivity() {
.setPositiveButton("Okay", null)
.show()
}
val sourceDestinationMap = if (source.isDirectory) {
check(destinationFile.isDirectory) { "Moving a directory to a file" }
// Recursively list all files (not directories) below `source`, then
// obtain the corresponding target file by resolving the relative path
// starting at the destination folder.
val sourceFiles = FileUtils.listFiles(source, null, true)
sourceFiles.associateWith { destinationFile.resolve(it.relativeTo(source)) }
} else {
mapOf(source to destinationFile)
}
if (!source.renameTo(destinationFile)) {
// TODO this should show a warning to the user
Timber.tag(TAG).e("Something went wrong while moving.")
} else {
AutofillMatcher.updateMatchesFor(this, sourceDestinationMap)
commitChange(this.resources
.getString(
R.string.git_commit_move_text,

View file

@ -6,7 +6,6 @@ package com.zeapo.pwdstore
import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutManager
import android.net.Uri
@ -20,6 +19,7 @@ import android.view.MenuItem
import android.view.accessibility.AccessibilityManager
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatTextView
import androidx.biometric.BiometricManager
import androidx.core.content.getSystemService
import androidx.documentfile.provider.DocumentFile
@ -32,6 +32,8 @@ import androidx.preference.SwitchPreferenceCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity
import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
import com.zeapo.pwdstore.crypto.PgpActivity
import com.zeapo.pwdstore.git.GitActivity
import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary
@ -40,6 +42,7 @@ import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity
import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.auth.AuthenticationResult
import com.zeapo.pwdstore.utils.auth.Authenticator
import com.zeapo.pwdstore.utils.autofillManager
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
@ -127,9 +130,7 @@ class UserPreference : AppCompatActivity() {
openkeystoreIdPreference?.isVisible = sharedPreferences.getString("ssh_openkeystore_keyid", null)?.isNotEmpty()
?: false
// see if the autofill service is enabled and check the preference accordingly
autoFillEnablePreference?.isChecked = callingActivity.isServiceEnabled
autofillDependencies.forEach { it?.isVisible = callingActivity.isServiceEnabled }
updateAutofillSettings()
appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}"
@ -242,24 +243,7 @@ class UserPreference : AppCompatActivity() {
}
autoFillEnablePreference?.onPreferenceClickListener = ClickListener {
var isEnabled = callingActivity.isServiceEnabled
if (isEnabled) {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
} else {
MaterialAlertDialogBuilder(callingActivity)
.setTitle(R.string.pref_autofill_enable_title)
.setView(R.layout.autofill_instructions)
.setPositiveButton(R.string.dialog_ok) { _, _ ->
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
.setNegativeButton(R.string.dialog_cancel, null)
.setOnDismissListener {
isEnabled = callingActivity.isServiceEnabled
autoFillEnablePreference?.isChecked = isEnabled
autofillDependencies.forEach { it?.isVisible = isEnabled }
}
.show()
}
onEnableAutofillClick()
true
}
@ -370,12 +354,80 @@ class UserPreference : AppCompatActivity() {
}
}
override fun onResume() {
super.onResume()
val isEnabled = callingActivity.isServiceEnabled
private fun updateAutofillSettings() {
val isAccessibilityServiceEnabled = callingActivity.isAccessibilityServiceEnabled
autoFillEnablePreference?.isChecked =
isAccessibilityServiceEnabled || callingActivity.isAutofillServiceEnabled
autofillDependencies.forEach {
it?.isVisible = isAccessibilityServiceEnabled
}
}
private fun onEnableAutofillClick() {
if (callingActivity.isAccessibilityServiceEnabled) {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
} else if (callingActivity.isAutofillServiceEnabled) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
callingActivity.autofillManager!!.disableAutofillServices()
else
throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O")
} else {
val enableOreoAutofill = callingActivity.isAutofillServiceSupported
MaterialAlertDialogBuilder(callingActivity).run {
setTitle(R.string.pref_autofill_enable_title)
if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val layout =
layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
val supportedBrowsersTextView =
layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
supportedBrowsersTextView.text =
getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(
separator = "\n"
) {
val appLabel = it.first
val supportDescription = when (it.second) {
BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support)
BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support)
BrowserAutofillSupportLevel.Fill -> getString(R.string.oreo_autofill_fill_support)
BrowserAutofillSupportLevel.FillAndSave -> getString(R.string.oreo_autofill_fill_and_save_support)
}
"$appLabel: $supportDescription"
}
setView(layout)
} else {
setView(R.layout.autofill_instructions)
}
setPositiveButton(R.string.dialog_ok) { _, _ ->
val intent =
if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
}
} else {
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
}
startActivity(intent)
}
setNegativeButton(R.string.dialog_cancel, null)
setOnDismissListener {
val isEnabled =
if (enableOreoAutofill && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
callingActivity.isAutofillServiceEnabled
} else {
callingActivity.isAccessibilityServiceEnabled
}
autoFillEnablePreference?.isChecked = isEnabled
autofillDependencies.forEach { it?.isVisible = isEnabled }
}
show()
}
}
}
override fun onResume() {
super.onResume()
updateAutofillSettings()
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
@ -487,11 +539,9 @@ class UserPreference : AppCompatActivity() {
}
}
// Returns whether the autofill service is enabled
private val isServiceEnabled: Boolean
private val isAccessibilityServiceEnabled: Boolean
get() {
val am = this
.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val am = getSystemService(AccessibilityManager::class.java)
val runningServices = am
.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC)
return runningServices
@ -499,6 +549,18 @@ class UserPreference : AppCompatActivity() {
.any { it == BuildConfig.APPLICATION_ID }
}
private val isAutofillServiceSupported: Boolean
get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return autofillManager?.isAutofillSupported != null
}
private val isAutofillServiceEnabled: Boolean
get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return autofillManager?.hasEnabledAutofillServices() == true
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,

View file

@ -0,0 +1,178 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.app.assist.AssistStructure
import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
import android.os.Build
import android.service.autofill.SaveCallback
import android.util.Base64
import android.view.autofill.AutofillId
import android.widget.RemoteViews
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R
import java.io.File
import java.security.MessageDigest
private fun ByteArray.sha256(): ByteArray {
return MessageDigest.getInstance("SHA-256").run {
update(this@sha256)
digest()
}
}
private fun ByteArray.base64(): String {
return Base64.encodeToString(this, Base64.NO_WRAP)
}
private fun stableHash(array: Collection<ByteArray>): String {
val hashes = array.map { it.sha256().base64() }
return hashes.sorted().joinToString(separator = ";")
}
/**
* Computes a stable hash of all certificates associated to the installed app with package name
* [appPackage].
*
* In most cases apps will only have a single certificate. If there are multiple, this functions
* returns all of them in sorted order and separated with `;`.
*/
fun computeCertificatesHash(context: Context, appPackage: String): String {
val signaturesOld =
context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures
val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
if (Build.VERSION.SDK_INT >= 28) {
val info = context.packageManager.getPackageInfo(
appPackage, PackageManager.GET_SIGNING_CERTIFICATES
)
val signaturesNew =
info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners
val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
if (stableHashNew != stableHashOld) tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" }
}
return stableHashOld
}
/**
* Returns the "origin" (without port information) of the [AssistStructure.ViewNode] derived from
* its `webDomain` and `webScheme`, if available.
*/
val AssistStructure.ViewNode.webOrigin: String?
@RequiresApi(Build.VERSION_CODES.O) get() = webDomain?.let { domain ->
val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https"
"$scheme://$domain"
}
data class Credentials(val username: String?, val password: String) {
companion object {
fun fromStoreEntry(file: File, entry: PasswordEntry): Credentials {
return if (entry.hasUsername()) Credentials(entry.username, entry.password)
else Credentials(file.nameWithoutExtension, entry.password)
}
}
}
private fun makeRemoteView(
context: Context,
title: String,
summary: String,
iconRes: Int
): RemoteViews {
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
setTextViewText(R.id.title, title)
setTextViewText(R.id.summary, summary)
setImageViewResource(R.id.icon, iconRes)
}
}
fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = false)
val summary = file.nameWithoutExtension
val iconRes = R.drawable.ic_person_black_24dp
return makeRemoteView(context, title, summary, iconRes)
}
fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_search_in_store)
val iconRes = R.drawable.ic_search_black_24dp
return makeRemoteView(context, title, summary, iconRes)
}
fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_generate_password)
val iconRes = R.drawable.ic_autofill_new_password
return makeRemoteView(context, title, summary, iconRes)
}
fun makePlaceholderRemoteView(context: Context): RemoteViews {
return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
}
fun makeWarningRemoteView(context: Context): RemoteViews {
val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title)
val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary)
val iconRes = R.drawable.ic_warning_red_24dp
return makeRemoteView(context, title, summary, iconRes)
}
@RequiresApi(Build.VERSION_CODES.O)
class FixedSaveCallback(context: Context, private val callback: SaveCallback) {
private val applicationContext = context.applicationContext
fun onFailure(message: CharSequence) {
callback.onFailure(message)
// When targeting SDK 29, the message is no longer shown as a toast.
// See https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence)
if (applicationContext.applicationInfo.targetSdkVersion >= 29) {
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
fun onSuccess(intentSender: IntentSender) {
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess(intentSender)
} else {
callback.onSuccess()
// On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in
// the context of the app that triggered the save request. Hence, we launch it here.
applicationContext.startIntentSender(intentSender, null, 0, 0, 0)
}
}
}
private fun visitViewNodes(structure: AssistStructure, block: (AssistStructure.ViewNode) -> Unit) {
for (i in 0 until structure.windowNodeCount) {
visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block)
}
}
private fun visitViewNode(
node: AssistStructure.ViewNode,
block: (AssistStructure.ViewNode) -> Unit
) {
block(node)
for (i in 0 until node.childCount) {
visitViewNode(node.getChildAt(i), block)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? {
var node: AssistStructure.ViewNode? = null
visitViewNodes(this) {
if (it.autofillId == autofillId)
node = it
}
return node
}

View file

@ -0,0 +1,178 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.content.SharedPreferences
import android.widget.Toast
import androidx.core.content.edit
import com.github.ajalt.timberkt.Timber.e
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.w
import com.zeapo.pwdstore.R
import java.io.File
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
private val Context.autofillAppMatches
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
private val Context.autofillWebMatches
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
return when (formOrigin) {
is FormOrigin.App -> autofillAppMatches
is FormOrigin.Web -> autofillWebMatches
}
}
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
init {
require(formOrigin is FormOrigin.App)
}
}
/**
* Manages "matches", i.e., associations between apps or websites and Password Store entries.
*/
class AutofillMatcher {
companion object {
private const val MAX_NUM_MATCHES = 10
private const val PREFERENCE_PREFIX_TOKEN = "token;"
private fun tokenKey(formOrigin: FormOrigin.App) =
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
private fun matchesKey(formOrigin: FormOrigin) =
"$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
return when (formOrigin) {
is FormOrigin.Web -> false
is FormOrigin.App -> {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
val storedCertificatesHash =
context.autofillAppMatches.getString(tokenKey(formOrigin), null)
?: return false
val hashHasChanged = certificatesHash != storedCertificatesHash
if (hashHasChanged) {
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
true
} else {
false
}
}
}
}
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
if (formOrigin is FormOrigin.App) {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
context.autofillAppMatches.edit {
putString(tokenKey(formOrigin), certificatesHash)
}
}
// We don't need to store a hash for FormOrigin.Web since it can only originate from
// browsers we trust to verify the origin.
}
/**
* Get all Password Store entries that have already been associated with [formOrigin] by the
* user.
*
* If [formOrigin] represents an app and that app's certificates have changed since the
* first time the user associated an entry with it, an [AutofillPublisherChangedException]
* will be thrown.
*/
fun getMatchesFor(context: Context, formOrigin: FormOrigin): List<File> {
if (hasFormOriginHashChanged(context, formOrigin)) {
throw AutofillPublisherChangedException(formOrigin)
}
val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles =
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
return matchedFiles.filter { it.exists() }.also { validFiles ->
matchPreferences.edit {
putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
}
}
}
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
context.matchPreferences(formOrigin).edit {
remove(matchesKey(formOrigin))
if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
}
}
/**
* Associates the store entry [file] with [formOrigin], such that future Autofill responses
* to requests from this app or website offer this entry as a dataset.
*
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of
* Android may crash when too many datasets are offered.
*/
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
if (!file.exists()) return
if (hasFormOriginHashChanged(context, formOrigin)) {
// This should never happen since we already verified the publisher in
// getMatchesFor.
e { "App publisher changed between getMatchesFor and addMatchFor" }
throw AutofillPublisherChangedException(formOrigin)
}
val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles =
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
if (newFiles.size > MAX_NUM_MATCHES) {
Toast.makeText(
context,
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
Toast.LENGTH_LONG
).show()
return
}
matchPreferences.edit {
putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
}
storeFormOriginHash(context, formOrigin)
d { "Stored match for $formOrigin" }
}
/**
* Goes through all existing matches and updates their associated entries by using
* [sourceDestinationMap] as a lookup table.
*/
fun updateMatchesFor(context: Context, sourceDestinationMap: Map<File, File>) {
val oldNewPathMap = sourceDestinationMap.mapValues { it.value.absolutePath }
.mapKeys { it.key.absolutePath }
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
for ((key, value) in prefs.all) {
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
val oldMatches = value as? Set<String>
if (oldMatches == null) {
w { "Failed to read matches for $key" }
continue
}
// Delete all matches for file locations that are going to be overwritten, then
// transfer matches over to the files at their new locations.
val newMatches =
oldMatches.asSequence().minus(oldNewPathMap.values).map { match ->
val newPath = oldNewPathMap[match] ?: return@map match
d { "Updating match for $key: $match --> $newPath" }
newPath
}.toSet()
if (newMatches != oldMatches)
prefs.edit { putStringSet(key, newMatches) }
}
}
}
}
}

View file

@ -0,0 +1,275 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.app.assist.AssistStructure
import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.e
enum class AutofillAction {
Match, Search, Generate
}
/**
* Represents a set of form fields with associated roles (e.g., username or new password) and
* contains the logic that decides which fields should be filled or saved. The type [T] is one of
* [FormField], [AssistStructure.ViewNode] or [AutofillId], depending on how much metadata about the
* field is needed and available in the particular situation.
*/
@RequiresApi(Build.VERSION_CODES.O)
sealed class AutofillScenario<out T : Any> {
companion object {
const val BUNDLE_KEY_USERNAME_ID = "usernameId"
const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
return try {
Builder<AutofillId>().apply {
username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
currentPassword.addAll(
clientState.getParcelableArrayList(
BUNDLE_KEY_CURRENT_PASSWORD_IDS
) ?: emptyList()
)
newPassword.addAll(
clientState.getParcelableArrayList(
BUNDLE_KEY_NEW_PASSWORD_IDS
) ?: emptyList()
)
genericPassword.addAll(
clientState.getParcelableArrayList(
BUNDLE_KEY_GENERIC_PASSWORD_IDS
) ?: emptyList()
)
}.build()
} catch (exception: IllegalArgumentException) {
e(exception)
null
}
}
}
class Builder<T : Any> {
var username: T? = null
var fillUsername = false
val currentPassword = mutableListOf<T>()
val newPassword = mutableListOf<T>()
val genericPassword = mutableListOf<T>()
fun build(): AutofillScenario<T> {
require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty()))
return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) {
ClassifiedAutofillScenario(
username = username,
fillUsername = fillUsername,
currentPassword = currentPassword,
newPassword = newPassword
)
} else {
GenericAutofillScenario(
username = username,
fillUsername = fillUsername,
genericPassword = genericPassword
)
}
}
}
abstract val username: T?
abstract val fillUsername: Boolean
abstract val allPasswordFields: List<T>
abstract val passwordFieldsToFillOnMatch: List<T>
abstract val passwordFieldsToFillOnSearch: List<T>
abstract val passwordFieldsToFillOnGenerate: List<T>
abstract val passwordFieldsToSave: List<T>
val fieldsToSave
get() = listOfNotNull(username) + passwordFieldsToSave
val allFields
get() = listOfNotNull(username) + allPasswordFields
fun fieldsToFillOn(action: AutofillAction): List<T> {
val passwordFieldsToFill = when (action) {
AutofillAction.Match -> passwordFieldsToFillOnMatch
AutofillAction.Search -> passwordFieldsToFillOnSearch
AutofillAction.Generate -> passwordFieldsToFillOnGenerate
}
return when {
passwordFieldsToFill.isNotEmpty() -> {
// If the current action would fill into any password field, we also fill into the
// username field if possible.
listOfNotNull(username.takeIf { fillUsername }) + passwordFieldsToFill
}
allPasswordFields.isEmpty() && action != AutofillAction.Generate -> {
// If there no password fields at all, we still offer to fill the username, e.g. in
// two-step login scenarios, but we do not offer to generate a password.
listOfNotNull(username.takeIf { fillUsername })
}
else -> emptyList()
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
data class ClassifiedAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
val currentPassword: List<T>,
val newPassword: List<T>
) : AutofillScenario<T>() {
override val allPasswordFields
get() = currentPassword + newPassword
override val passwordFieldsToFillOnMatch
get() = currentPassword
override val passwordFieldsToFillOnSearch
get() = currentPassword
override val passwordFieldsToFillOnGenerate
get() = newPassword
override val passwordFieldsToSave
get() = if (newPassword.isNotEmpty()) newPassword else currentPassword
}
@RequiresApi(Build.VERSION_CODES.O)
data class GenericAutofillScenario<T : Any>(
override val username: T?,
override val fillUsername: Boolean,
val genericPassword: List<T>
) : AutofillScenario<T>() {
override val allPasswordFields
get() = genericPassword
override val passwordFieldsToFillOnMatch
get() = if (genericPassword.size == 1) genericPassword else emptyList()
override val passwordFieldsToFillOnSearch
get() = if (genericPassword.size == 1) genericPassword else emptyList()
override val passwordFieldsToFillOnGenerate
get() = genericPassword
override val passwordFieldsToSave
get() = genericPassword
}
fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boolean): Boolean {
return if (singleOriginMode) {
// In single origin mode, only the browsers URL bar (which is never filled) should have
// a webOrigin.
allFields.all { it.webOrigin == null }
} else {
// In apps or browsers in multi origin mode, every field in a dataset has to belong to
// the same (possibly null) origin.
allFields.map { it.webOrigin }.toSet().size == 1
}
}
@RequiresApi(Build.VERSION_CODES.O)
@JvmName("fillWithAutofillId")
fun Dataset.Builder.fillWith(
scenario: AutofillScenario<AutofillId>,
action: AutofillAction,
credentials: Credentials?
) {
val credentialsToFill = credentials ?: Credentials(
"USERNAME",
"PASSWORD"
)
for (field in scenario.fieldsToFillOn(action)) {
val value = if (field == scenario.username) {
credentialsToFill.username
} else {
credentialsToFill.password
} ?: continue
setValue(field, AutofillValue.forText(value))
}
}
@RequiresApi(Build.VERSION_CODES.O)
@JvmName("fillWithFormField")
fun Dataset.Builder.fillWith(
scenario: AutofillScenario<FormField>,
action: AutofillAction,
credentials: Credentials?
) {
fillWith(scenario.map { it.autofillId }, action, credentials)
}
inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): AutofillScenario<S> {
val builder = AutofillScenario.Builder<S>()
builder.username = username?.let(transform)
builder.fillUsername = fillUsername
when (this) {
is ClassifiedAutofillScenario -> {
builder.currentPassword.addAll(currentPassword.map(transform))
builder.newPassword.addAll(newPassword.map(transform))
}
is GenericAutofillScenario -> {
builder.genericPassword.addAll(genericPassword.map(transform))
}
}
return builder.build()
}
@RequiresApi(Build.VERSION_CODES.O)
@JvmName("toBundleAutofillId")
private fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
is ClassifiedAutofillScenario<AutofillId> -> {
Bundle(4).apply {
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)
)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword)
)
}
}
is GenericAutofillScenario<AutofillId> -> {
Bundle(3).apply {
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
putParcelableArrayList(
AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword)
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@JvmName("toBundleFormField")
fun AutofillScenario<FormField>.toBundle(): Bundle = map { it.autofillId }.toBundle()
@RequiresApi(Build.VERSION_CODES.O)
fun AutofillScenario<AutofillId>.recoverNodes(structure: AssistStructure): AutofillScenario<AssistStructure.ViewNode>? {
return map { autofillId ->
structure.findNodeByAutofillId(autofillId) ?: return null
}
}
val AutofillScenario<AssistStructure.ViewNode>.usernameValue: String?
@RequiresApi(Build.VERSION_CODES.O) get() {
val value = username?.autofillValue ?: return null
return if (value.isText) value.textValue.toString() else null
}
val AutofillScenario<AssistStructure.ViewNode>.passwordValue: String?
@RequiresApi(Build.VERSION_CODES.O) get() {
val distinctValues = passwordFieldsToSave.map {
if (it.autofillValue?.isText == true) {
it.autofillValue?.textValue?.toString()
} else {
null
}
}.toSet()
// Only return a non-null password value when all password fields agree
return distinctValues.singleOrNull()
}

View file

@ -0,0 +1,179 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.os.Build
import androidx.annotation.RequiresApi
import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Certain
import com.zeapo.pwdstore.autofill.oreo.CertaintyLevel.Likely
private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) =
predicate(first) && predicate(second)
private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) =
predicate(first) || predicate(second)
private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) =
!predicate(first) && !predicate(second)
/**
* The strategy used to detect [AutofillScenario]s; expressed using the DSL implemented in
* [AutofillDsl].
*/
@RequiresApi(Build.VERSION_CODES.O)
val autofillStrategy = strategy {
// Match two new password fields, an optional current password field right below or above, and
// an optional username field with autocomplete hint.
// TODO: Introduce a custom fill/generate/update flow for this scenario
rule {
newPassword {
takePair { all { hasAutocompleteHintNewPassword } }
breakTieOnPair { any { isFocused } }
}
currentPassword(optional = true) {
takeSingle { alreadyMatched ->
val adjacentToNewPasswords =
directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
hasAutocompleteHintCurrentPassword && adjacentToNewPasswords
}
}
username(optional = true) {
takeSingle { hasAutocompleteHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match a single focused current password field and hidden username field with autocomplete
// hint. This configuration is commonly used in two-step login flows to allow password managers
// to save the username.
// See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
// Note: The username is never filled in this scenario since usernames are generally only filled
// in visible fields.
rule {
username(matchHidden = true) {
takeSingle {
couldBeTwoStepHiddenUsername
}
}
currentPassword {
takeSingle { alreadyMatched ->
hasAutocompleteHintCurrentPassword && isFocused
}
}
}
// Match a single current password field and optional username field with autocomplete hint.
rule {
currentPassword {
takeSingle { hasAutocompleteHintCurrentPassword }
breakTieOnSingle { isFocused }
}
username(optional = true) {
takeSingle { hasAutocompleteHintUsername }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match two adjacent password fields, implicitly understood as new passwords, and optional
// username field.
rule {
newPassword {
takePair { all { passwordCertainty >= Likely } }
breakTieOnPair { all { passwordCertainty >= Certain } }
breakTieOnPair { any { isFocused } }
}
username(optional = true) {
takeSingle()
breakTieOnSingle { usernameCertainty >= Likely }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match a single password field and optional username field.
rule {
genericPassword {
takeSingle { passwordCertainty >= Likely }
breakTieOnSingle { passwordCertainty >= Certain }
breakTieOnSingle { isFocused }
}
username(optional = true) {
takeSingle()
breakTieOnSingle { usernameCertainty >= Likely }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
breakTieOnSingle { isFocused }
}
}
// Match a single focused new password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
newPassword {
takeSingle { hasAutocompleteHintNewPassword && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
}
}
}
// Match a single focused current password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
currentPassword {
takeSingle { hasAutocompleteHintCurrentPassword && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
}
}
}
// Match a single focused password field and optional preceding username field.
// This rule can apply in single origin mode since it only fills into a single focused password
// field.
rule(applyInSingleOriginMode = true) {
genericPassword {
takeSingle { passwordCertainty >= Likely && isFocused }
}
username(optional = true) {
takeSingle { alreadyMatched ->
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
}
}
}
// Match a focused username field with autocomplete hint directly followed by a hidden password
// field, which is a common scenario in two-step login flows. No tie breakers are used to limit
// filling of hidden password fields to scenarios where this is clearly warranted.
rule {
username {
takeSingle { hasAutocompleteHintUsername && isFocused }
}
currentPassword(matchHidden = true) {
takeSingle { alreadyMatched ->
directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword
}
}
}
// Match a single focused username field without a password field.
rule(applyInSingleOriginMode = true) {
username {
takeSingle { usernameCertainty >= Likely && isFocused }
breakTieOnSingle { usernameCertainty >= Certain }
breakTieOnSingle { hasAutocompleteHintUsername }
}
}
}

View file

@ -0,0 +1,328 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.os.Build
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.w
@DslMarker
annotation class AutofillDsl
@RequiresApi(Build.VERSION_CODES.O)
interface FieldMatcher {
fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>?
@AutofillDsl
class Builder {
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
mutableListOf()
private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null
private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> =
mutableListOf()
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
takeSingle = block
}
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
tieBreakersSingle.add(block)
}
fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
takePair = block
}
fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
tieBreakersPair.add(block)
}
fun build(): FieldMatcher {
val takeSingle = takeSingle
val takePair = takePair
return when {
takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle)
takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair)
else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block")
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
class SingleFieldMatcher(
private val take: (FormField, List<FormField>) -> Boolean,
private val tieBreakers: List<(FormField, List<FormField>) -> Boolean>
) : FieldMatcher {
@AutofillDsl
class Builder {
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
mutableListOf()
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
takeSingle = block
}
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
tieBreakersSingle.add(block)
}
fun build() = SingleFieldMatcher(
takeSingle
?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"),
tieBreakersSingle
)
}
override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants ->
var current = contestants
for ((i, tieBreaker) in tieBreakers.withIndex()) {
// Successively filter matched fields via tie breakers...
val new = current.filter { tieBreaker(it, alreadyMatched) }
// skipping those tie breakers that are not satisfied for any remaining field...
if (new.isEmpty()) {
d { "Tie breaker #${i + 1}: Didn't match any field; skipping" }
continue
}
// and return if the available options have been narrowed to a single field.
if (new.size == 1) {
d { "Tie breaker #${i + 1}: Success" }
current = new
break
}
d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" }
current = new
}
listOf(current.singleOrNull() ?: return null)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private class PairOfFieldsMatcher(
private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean,
private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean>
) : FieldMatcher {
override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
return fields.minus(alreadyMatched).zipWithNext()
.filter { it.first directlyPrecedes it.second }.filter { take(it, alreadyMatched) }
.let { contestants ->
var current = contestants
for ((i, tieBreaker) in tieBreakers.withIndex()) {
val new = current.filter { tieBreaker(it, alreadyMatched) }
if (new.isEmpty()) {
d { "Tie breaker #${i + 1}: Didn't match any field; skipping" }
continue
}
// and return if the available options have been narrowed to a single field.
if (new.size == 1) {
d { "Tie breaker #${i + 1}: Success" }
current = new
break
}
d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" }
current = new
}
current.singleOrNull()?.toList()
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
class AutofillRule private constructor(
private val matchers: List<AutofillRuleMatcher>,
private val applyInSingleOriginMode: Boolean,
private val name: String
) {
data class AutofillRuleMatcher(
val type: FillableFieldType,
val matcher: FieldMatcher,
val optional: Boolean,
val matchHidden: Boolean
)
enum class FillableFieldType {
Username, CurrentPassword, NewPassword, GenericPassword,
}
@AutofillDsl
class Builder(private val applyInSingleOriginMode: Boolean) {
companion object {
private var ruleId = 1
}
private val matchers = mutableListOf<AutofillRuleMatcher>()
var name: String? = null
fun username(optional: Boolean = false, matchHidden: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.Username }) { "Every rule block can only have at most one username block" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.Username,
matcher = SingleFieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = matchHidden
)
)
}
fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.CurrentPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = matchHidden
)
)
}
fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.NewPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
require(matchers.none {
it.type in listOf(
FillableFieldType.CurrentPassword, FillableFieldType.NewPassword
)
}) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
matchers.add(
AutofillRuleMatcher(
type = FillableFieldType.GenericPassword,
matcher = FieldMatcher.Builder().apply(block).build(),
optional = optional,
matchHidden = false
)
)
}
fun build(): AutofillRule {
if (applyInSingleOriginMode) {
require(matchers.none { it.matcher is PairOfFieldsMatcher }) { "Rules with applyInSingleOriginMode set to true must only match single fields" }
require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { "Rules with applyInSingleOriginMode set to true must only match at most one password field" }
require(matchers.none { it.matchHidden }) { "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" }
}
return AutofillRule(
matchers, applyInSingleOriginMode, name ?: "Rule #$ruleId"
).also { ruleId++ }
}
}
fun apply(
allPassword: List<FormField>,
allUsername: List<FormField>,
singleOriginMode: Boolean
): AutofillScenario<FormField>? {
if (singleOriginMode && !applyInSingleOriginMode) {
d { "$name: Skipped in single origin mode" }
return null
}
d { "$name: Applying..." }
val scenarioBuilder = AutofillScenario.Builder<FormField>()
val alreadyMatched = mutableListOf<FormField>()
for ((type, matcher, optional, matchHidden) in matchers) {
val fieldsToMatchOn = when (type) {
FillableFieldType.Username -> allUsername
else -> allPassword
}.filter { matchHidden || it.isVisible }
val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) {
d { "$name: Skipping optional $type matcher" }
continue
} else {
d { "$name: Required $type matcher didn't match; passing to next rule" }
return null
}
d { "$name: Matched $type" }
when (type) {
FillableFieldType.Username -> {
check(matchResult.size == 1 && scenarioBuilder.username == null)
scenarioBuilder.username = matchResult.single()
// Hidden username fields should be saved but not filled.
scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
}
FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(
matchResult
)
FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult)
FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(
matchResult
)
}
alreadyMatched.addAll(matchResult)
}
return scenarioBuilder.build().takeIf { scenario ->
scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed ->
if (passed) {
d { "$name: Detected scenario:\n$scenario" }
} else {
w { "$name: Scenario failed origin check:\n$scenario" }
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
class AutofillStrategy private constructor(private val rules: List<AutofillRule>) {
@AutofillDsl
class Builder {
private val rules: MutableList<AutofillRule> = mutableListOf()
fun rule(
applyInSingleOriginMode: Boolean = false,
block: AutofillRule.Builder.() -> Unit
) {
rules.add(AutofillRule.Builder(applyInSingleOriginMode).apply(block).build())
}
fun build() = AutofillStrategy(rules)
}
fun apply(fields: List<FormField>, multiOriginSupport: Boolean): AutofillScenario<FormField>? {
val possiblePasswordFields =
fields.filter { it.passwordCertainty >= CertaintyLevel.Possible }
d { "Possible password fields: ${possiblePasswordFields.size}" }
val possibleUsernameFields =
fields.filter { it.usernameCertainty >= CertaintyLevel.Possible }
d { "Possible username fields: ${possibleUsernameFields.size}" }
// Return the result of the first rule that matches
d { "Rules: ${rules.size}" }
for (rule in rules) {
return rule.apply(possiblePasswordFields, possibleUsernameFields, multiOriginSupport)
?: continue
}
return null
}
}
fun strategy(block: AutofillStrategy.Builder.() -> Unit) =
AutofillStrategy.Builder().apply(block).build()

View file

@ -0,0 +1,199 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
/*
In order to add a new browser, do the following:
1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your
phone and use adb pull to get it onto your computer. We will assume that it is called
browser.apk.
2. Run
aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8-
to obtain the package name (actually, the application ID) of the app in the .apk.
3. Run
apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64
to calculate the hash of browser.apk's first signing certificate.
Note: This will only work if the apk has a single signing certificate. Apps with multiple
signers are very rare, so there is probably no need to add them.
Refer to computeCertificatesHash to learn how the hash would be computed in this case.
4. Verify the package name and the hash, for example by asking other people to repeat the steps
above.
5. Add an entry with the browser apps's package name and the hash to
TRUSTED_BROWSER_CERTIFICATE_HASH.
6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check
whether a save request to Password Store is triggered when you submit a registration form.
7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and
check whether it correctly distinguishes web origins even if iframes are present on the page.
You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form.
*/
/*
* **Security assumption**: Browsers on this list correctly report the web origin of the top-level
* window as part of their AssistStructure.
*
* Note: Browsers can be on this list even if they don't report the correct web origins of all
* fields on the page, e.g. of those in iframes.
*/
private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf(
"com.android.chrome" to "8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M=",
"com.brave.browser" to "nC23BRNRX9v7vFhbPt89cSPU3GfJT/0wY2HB15u/GKw=",
"com.chrome.beta" to "2mM9NLaeY64hA7SdU84FL8X388U6q5T9wqIIvf0UJJw=",
"com.chrome.canary" to "IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw=",
"com.chrome.dev" to "kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8=",
"com.duckduckgo.mobile.android" to "u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=",
"com.microsoft.emmx" to "AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk=",
"com.opera.mini.native" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=",
"com.opera.mini.native.beta" to "V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I=",
"com.opera.touch" to "qtjiBNJNF3k0yc0MY8xqo4779CxKaVcJfiIQ9X+qZ6o=",
"org.mozilla.fenix" to "UAR3kIjn+YjVvFzF+HmP6/T4zQhKGypG79TI7krq8hE=",
"org.mozilla.fenix.nightly" to "d+rEzu02r++6dheZMd1MwZWrDNVLrzVdIV57vdKOQCo=",
"org.mozilla.fennec_aurora" to "vASIg40G9Mpr8yOG2qsN2OvPPncweHRZ9i+zzRShuqo=",
"org.mozilla.fennec_fdroid" to "BmZTWO/YugW+I2pHoSywlY19dd2TnXfCsx9TmFN+vcU=",
"org.mozilla.firefox" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=",
"org.mozilla.firefox_beta" to "p4tipRZbRJSy/q2edqKA0i2Tf+5iUa7OWZRGsuoxmwQ=",
"org.mozilla.focus" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=",
"org.mozilla.klar" to "YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w=",
"org.torproject.torbrowser" to "IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg="
)
private fun isTrustedBrowser(context: Context, appPackage: String): Boolean {
val expectedCertificateHash = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false
val certificateHash = computeCertificatesHash(context, appPackage)
return certificateHash == expectedCertificateHash
}
enum class BrowserMultiOriginMethod {
None, WebView, Field
}
/**
* **Security assumption**: Browsers on this list correctly distinguish the web origins of form
* fields, e.g. on a page which contains both a first-party login form and an iframe with a
* (potentially malicious) third-party login form.
*
* There are two methods used by browsers:
* - Browsers based on Android's WebView report web domains on each WebView view node, which then
* needs to be propagated to the child nodes ([BrowserMultiOriginMethod.WebView]).
* - Browsers with custom Autofill implementations report web domains on each input field (
* [BrowserMultiOriginMethod.Field]).
*/
private val BROWSER_MULTI_ORIGIN_METHOD = mapOf(
"com.duckduckgo.mobile.android" to BrowserMultiOriginMethod.WebView,
"com.opera.mini.native" to BrowserMultiOriginMethod.WebView,
"com.opera.mini.native.beta" to BrowserMultiOriginMethod.WebView,
"com.opera.touch" to BrowserMultiOriginMethod.WebView,
"org.mozilla.fenix" to BrowserMultiOriginMethod.Field,
"org.mozilla.fenix.nightly" to BrowserMultiOriginMethod.Field,
"org.mozilla.fennec_aurora" to BrowserMultiOriginMethod.Field,
"org.mozilla.fennec_fdroid" to BrowserMultiOriginMethod.Field,
"org.mozilla.firefox" to BrowserMultiOriginMethod.WebView,
"org.mozilla.firefox_beta" to BrowserMultiOriginMethod.WebView,
"org.mozilla.focus" to BrowserMultiOriginMethod.Field,
"org.mozilla.klar" to BrowserMultiOriginMethod.Field,
"org.torproject.torbrowser" to BrowserMultiOriginMethod.WebView
)
private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginMethod =
BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None
/**
* Browsers on this list issue Autofill save requests and provide unmasked passwords as
* `autofillValue`.
*
* Some browsers may not issue save requests automatically and thus need
* `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set.
*/
@RequiresApi(Build.VERSION_CODES.O)
private val BROWSER_SAVE_FLAG = mapOf(
"com.duckduckgo.mobile.android" to 0,
"org.mozilla.klar" to 0,
"org.mozilla.focus" to 0,
"org.mozilla.fenix" to 0,
"org.mozilla.fenix.nightly" to 0,
"org.mozilla.fennec_aurora" to 0,
"com.opera.mini.native" to 0,
"com.opera.mini.native.beta" to 0,
"com.opera.touch" to 0
)
@RequiresApi(Build.VERSION_CODES.O)
private fun getBrowserSaveFlag(appPackage: String): Int? = BROWSER_SAVE_FLAG[appPackage]
data class BrowserAutofillSupportInfo(
val multiOriginMethod: BrowserMultiOriginMethod,
val saveFlag: Int?
)
@RequiresApi(Build.VERSION_CODES.O)
fun getBrowserAutofillSupportInfoIfTrusted(
context: Context,
appPackage: String
): BrowserAutofillSupportInfo? {
if (!isTrustedBrowser(context, appPackage)) return null
return BrowserAutofillSupportInfo(
multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
saveFlag = getBrowserSaveFlag(appPackage)
)
}
private val FLAKY_BROWSERS = listOf(
"com.android.chrome"
)
enum class BrowserAutofillSupportLevel {
None,
FlakyFill,
Fill,
FillAndSave
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getBrowserAutofillSupportLevel(
context: Context,
appPackage: String
): BrowserAutofillSupportLevel {
val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
return when {
browserInfo == null -> BrowserAutofillSupportLevel.None
browserInfo.saveFlag != null -> BrowserAutofillSupportLevel.FillAndSave
appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
else -> BrowserAutofillSupportLevel.Fill
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getInstalledBrowsersWithAutofillSupportLevel(context: Context): List<Pair<String, BrowserAutofillSupportLevel>> {
val testWebIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("http://example.org")
}
val installedBrowsers = context.packageManager.queryIntentActivities(
testWebIntent,
PackageManager.MATCH_ALL
)
return installedBrowsers.map {
it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName)
}.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }.map {
context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo)
.toString() to it.second
}
}

View file

@ -0,0 +1,354 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.app.assist.AssistStructure
import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import android.view.autofill.AutofillId
import android.widget.RemoteViews
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
import java.io.File
/**
* A unique identifier for either an Android app (package name) or a website (origin minus port).
*/
sealed class FormOrigin(open val identifier: String) {
data class Web(override val identifier: String) : FormOrigin(identifier)
data class App(override val identifier: String) : FormOrigin(identifier)
companion object {
private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
fun fromBundle(bundle: Bundle): FormOrigin? {
val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER)
if (webIdentifier != null) {
return Web(webIdentifier)
} else {
return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null)
}
}
}
fun getPrettyIdentifier(context: Context, untrusted: Boolean = true) = when (this) {
is Web -> identifier
is App -> {
val info = context.packageManager.getApplicationInfo(
identifier, PackageManager.GET_META_DATA
)
val label = context.packageManager.getApplicationLabel(info)
if (untrusted) "$label" else "$label"
}
}
fun toBundle() = when (this) {
is Web -> bundleOf(BUNDLE_KEY_WEB_IDENTIFIER to identifier)
is App -> bundleOf(BUNDLE_KEY_APP_IDENTIFIER to identifier)
}
}
/**
* Manages the detection of fields to fill in an [AssistStructure] and determines the [FormOrigin].
*/
@RequiresApi(Build.VERSION_CODES.O)
private class Form(context: Context, structure: AssistStructure) {
companion object {
private val SUPPORTED_SCHEMES = listOf("http", "https")
}
private val relevantFields = mutableListOf<FormField>()
val ignoredIds = mutableListOf<AutofillId>()
private var fieldIndex = 0
private var appPackage = structure.activityComponent.packageName
private val browserAutofillSupportInfo =
getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
private val isTrustedBrowser = browserAutofillSupportInfo != null
private val browserMultiOriginMethod =
browserAutofillSupportInfo?.multiOriginMethod ?: BrowserMultiOriginMethod.None
private val singleOriginMode = browserMultiOriginMethod == BrowserMultiOriginMethod.None
val saveFlags = browserAutofillSupportInfo?.saveFlag
private val webOrigins = mutableSetOf<String>()
init {
d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
parseStructure(structure)
}
val scenario = detectFieldsToFill()
val formOrigin = determineFormOrigin(context)
init {
d { "Origin: $formOrigin" }
}
private fun parseStructure(structure: AssistStructure) {
for (i in 0 until structure.windowNodeCount) {
visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
}
}
private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
trackOrigin(node)
val field =
if (browserMultiOriginMethod == BrowserMultiOriginMethod.WebView) {
FormField(node, fieldIndex, true, inheritedWebOrigin)
} else {
check(inheritedWebOrigin == null)
FormField(node, fieldIndex, false)
}
if (field.relevantField) {
d { "Relevant: $field" }
relevantFields.add(field)
fieldIndex++
} else {
d { "Ignored : $field" }
ignoredIds.add(field.autofillId)
}
for (i in 0 until node.childCount) {
visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
}
}
private fun detectFieldsToFill() = autofillStrategy.apply(relevantFields, singleOriginMode)
private fun trackOrigin(node: AssistStructure.ViewNode) {
if (!isTrustedBrowser) return
node.webOrigin?.let {
if (it !in webOrigins) {
d { "Origin encountered: $it" }
webOrigins.add(it)
}
}
}
private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
val uri = Uri.parse(origin) ?: return null
val scheme = uri.scheme ?: return null
if (scheme !in SUPPORTED_SCHEMES) return null
val host = uri.host ?: return null
return FormOrigin.Web(getPublicSuffixPlusOne(context, host))
}
private fun determineFormOrigin(context: Context): FormOrigin? {
if (scenario == null) return null
if (!isTrustedBrowser || webOrigins.isEmpty()) {
// Security assumption: If a trusted browser includes no web origin in the provided
// AssistStructure, then the form is a native browser form (e.g. for a sync password).
// TODO: Support WebViews in apps via Digital Asset Links
// See: https://developer.android.com/reference/android/service/autofill/AutofillService#web-security
return FormOrigin.App(appPackage)
}
return when (browserMultiOriginMethod) {
BrowserMultiOriginMethod.None -> {
// Security assumption: If a browser is trusted but does not support tracking
// multiple origins, it is expected to annotate a single field, in most cases its
// URL bar, with a webOrigin. We err on the side of caution and only trust the
// reported web origin if no other web origin appears on the page.
webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null)
}
BrowserMultiOriginMethod.WebView,
BrowserMultiOriginMethod.Field -> {
// Security assumption: For browsers with full autofill support (the `Field` case),
// every form field is annotated with its origin. For browsers based on WebView,
// this is true after the web origins of WebViews are passed down to their children.
//
// For browsers with the WebView or Field method of multi origin support, we take
// the single origin among the detected fillable or saveable fields. If this origin
// is null, but we encountered web origins elsewhere in the AssistStructure, the
// situation is uncertain and Autofill should not be offered.
webOriginToFormOrigin(
context,
scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null
)
}
}
}
}
/**
* Represents a collection of fields in a specific app that can be filled or saved. This is the
* entry point to all fill and save features.
*/
@RequiresApi(Build.VERSION_CODES.O)
class FillableForm private constructor(
private val formOrigin: FormOrigin,
private val scenario: AutofillScenario<FormField>,
private val ignoredIds: List<AutofillId>,
private val saveFlags: Int?
) {
companion object {
fun makeFillInDataset(
context: Context,
credentials: Credentials,
clientState: Bundle,
action: AutofillAction
): Dataset {
val remoteView = makePlaceholderRemoteView(context)
val scenario = AutofillScenario.fromBundle(clientState)
return Dataset.Builder(remoteView).run {
if (scenario != null) fillWith(scenario, action, credentials)
else e { "Failed to recover scenario from client state" }
build()
}
}
/**
* Returns a [FillableForm] if a login form could be detected in [structure].
*/
fun parseAssistStructure(context: Context, structure: AssistStructure): FillableForm? {
val form = Form(context, structure)
if (form.formOrigin == null || form.scenario == null) return null
return FillableForm(
form.formOrigin,
form.scenario,
form.ignoredIds,
form.saveFlags
)
}
}
private val clientState = scenario.toBundle().apply {
putAll(formOrigin.toBundle())
}
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave =
scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty()
private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makePlaceholderDataset(
remoteView: RemoteViews,
intentSender: IntentSender,
action: AutofillAction
): Dataset {
return Dataset.Builder(remoteView).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
build()
}
}
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null
val remoteView = makeFillMatchRemoteView(context, file, formOrigin)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
}
private fun makeSearchDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null
val remoteView = makeSearchAndFillRemoteView(context, formOrigin)
val intentSender =
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search)
}
private fun makeGenerateDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null
val remoteView = makeGenerateAndFillRemoteView(context, formOrigin)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): Dataset {
val remoteView = makeWarningRemoteView(context)
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context, publisherChangedException
)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match)
}
private fun makePublisherChangedResponse(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.map { it.autofillId }.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.username != null) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
var hasDataset = false
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file)?.let {
hasDataset = true
addDataset(it)
}
}
makeSearchDataset(context)?.let {
hasDataset = true
addDataset(it)
}
makeGenerateDataset(context)?.let {
hasDataset = true
addDataset(it)
}
if (!hasDataset) return null
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
/**
* Creates and returns a suitable [FillResponse] to the Autofill framework.
*/
fun fillCredentials(context: Context, callback: FillCallback) {
val matchedFiles = try {
AutofillMatcher.getMatchesFor(context, formOrigin)
} catch (publisherChangedException: AutofillPublisherChangedException) {
e(publisherChangedException)
callback.onSuccess(makePublisherChangedResponse(context, publisherChangedException))
return
}
callback.onSuccess(makeFillResponse(context, matchedFiles))
}
}

View file

@ -0,0 +1,240 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.app.assist.AssistStructure
import android.os.Build
import android.text.InputType
import android.view.View
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
import java.util.Locale
enum class CertaintyLevel {
Impossible, Possible, Likely, Certain
}
/**
* Represents a single potentially fillable or saveable field together with all meta data
* extracted from its [AssistStructure.ViewNode].
*/
@RequiresApi(Build.VERSION_CODES.O)
class FormField(
node: AssistStructure.ViewNode,
private val index: Int,
passDownWebViewOrigins: Boolean,
passedDownWebOrigin: String? = null
) {
companion object {
@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_USERNAME = listOf(View.AUTOFILL_HINT_USERNAME)
@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_PASSWORD = listOf(View.AUTOFILL_HINT_PASSWORD)
@RequiresApi(Build.VERSION_CODES.O)
private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + listOf(
View.AUTOFILL_HINT_EMAIL_ADDRESS, View.AUTOFILL_HINT_NAME, View.AUTOFILL_HINT_PHONE
)
private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf(
"android.widget.EditText",
"android.widget.AutoCompleteTextView",
"androidx.appcompat.widget.AppCompatEditText",
"android.support.v7.widget.AppCompatEditText",
"com.google.android.material.textfield.TextInputEditText"
)
private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView"
private fun isPasswordInputType(inputType: Int): Boolean {
val typeClass = inputType and InputType.TYPE_MASK_CLASS
val typeVariation = inputType and InputType.TYPE_MASK_VARIATION
return when (typeClass) {
InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
InputType.TYPE_CLASS_TEXT -> typeVariation in listOf(
InputType.TYPE_TEXT_VARIATION_PASSWORD,
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,
InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
)
else -> false
}
}
private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf("email", "tel", "text")
private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf("password")
private val HTML_INPUT_FIELD_TYPES_FILLABLE =
HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD
@RequiresApi(Build.VERSION_CODES.O)
private fun isSupportedHint(hint: String) = hint in HINTS_USERNAME + HINTS_PASSWORD
private val EXCLUDED_TERMS = listOf(
"url_bar", // Chrome/Edge/Firefox address bar
"url_field", // Opera address bar
"location_bar_edit_text", // Samsung address bar
"search", "find", "captcha"
)
private val PASSWORD_HEURISTIC_TERMS = listOf(
"password", "pwd", "pswd", "passwort"
)
private val USERNAME_HEURISTIC_TERMS = listOf(
"user", "name", "email"
)
}
val autofillId: AutofillId = node.autofillId!!
// Information for heuristics and exclusion rules based only on the current field
private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second
private val resourceId = node.idEntry
private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US)
private val hint = node.hint?.toLowerCase(Locale.US) ?: ""
private val className: String? = node.className
private val inputType = node.inputType
// Information for advanced heuristics taking multiple fields and page context into account
val isFocused = node.isFocused
// The webOrigin of a WebView should be passed down to its children in certain browsers
private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME
val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null
val webOriginToPassDown = if (passDownWebViewOrigins) {
if (isWebView) webOrigin else passedDownWebOrigin
} else {
null
}
// Basic type detection for HTML fields
private val htmlTag = node.htmlInfo?.tag
private val htmlAttributes: Map<String, String> =
node.htmlInfo?.attributes?.filter { it.first != null && it.second != null }
?.associate { Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US)) }
?: emptyMap()
private val htmlAttributesDebug =
htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
private val htmlInputType = htmlAttributes["type"]
private val htmlName = htmlAttributes["name"] ?: ""
private val isHtmlField = htmlTag == "input"
private val isHtmlPasswordField =
isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD
private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE
// Basic type detection for native fields
private val hasPasswordInputType = isPasswordInputType(inputType)
// HTML fields with non-fillable types (such as submit buttons) should be excluded here
private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES
private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType
private val isTextField = isAndroidTextField || isHtmlTextField
// Autofill hint detection for native fields
private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList()
private val excludedByAutofillHints =
if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
private val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
// W3C autocomplete hint detection for HTML fields
private val htmlAutocomplete = htmlAttributes["autocomplete"]
// Ignored for now, see excludedByHints
private val excludedByAutocompleteHint = htmlAutocomplete == "off"
val hasAutocompleteHintUsername = htmlAutocomplete == "username"
val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
private val hasAutocompleteHintPassword =
hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
// Basic autofill exclusion checks
private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true"
// Hidden username fields are used to help password managers save credentials in two-step login
// flows.
// See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername
// Some websites with two-step login flows offer hidden password fields to fill the password
// already in the first step. Thus, we delegate the decision about filling invisible password
// fields to the fill rules and only exclude those fields that have incompatible autocomplete
// hint.
val couldBeTwoStepHiddenPassword =
!isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null)
// Since many site put autocomplete=off on login forms for compliance reasons or since they are
// worried of the user's browser automatically (i.e., without any user interaction) filling
// them, which we never do, we choose to ignore the value of excludedByAutocompleteHint.
// TODO: Revisit this decision in the future
private val excludedByHints = excludedByAutofillHints
val relevantField = isTextField && hasAutofillTypeText && !excludedByHints
// Exclude fields based on hint and resource ID
// Note: We still report excluded fields as relevant since they count for adjacency heuristics,
// but ensure that they are never detected as password or username fields.
private val hasExcludedTerm = EXCLUDED_TERMS.any { fieldId.contains(it) || hint.contains(it) }
private val notExcluded = relevantField && !hasExcludedTerm
// Password field heuristics (based only on the current field)
private val isPossiblePasswordField =
notExcluded && (isAndroidPasswordField || isHtmlPasswordField)
private val isCertainPasswordField =
isPossiblePasswordField && (isHtmlPasswordField || hasAutofillHintPassword || hasAutocompleteHintPassword)
private val isLikelyPasswordField = isPossiblePasswordField && (isCertainPasswordField || (PASSWORD_HEURISTIC_TERMS.any {
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
}))
val passwordCertainty =
if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
// Username field heuristics (based only on the current field)
private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField
private val isCertainUsernameField =
isPossibleUsernameField && (hasAutofillHintUsername || hasAutocompleteHintUsername)
private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.any {
fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
}))
val usernameCertainty =
if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible
infix fun directlyPrecedes(that: FormField?): Boolean {
return index == (that ?: return false).index - 1
}
infix fun directlyPrecedes(that: Iterable<FormField>): Boolean {
val firstIndex = that.map { it.index }.min() ?: return false
return index == firstIndex - 1
}
infix fun directlyFollows(that: FormField?): Boolean {
return index == (that ?: return false).index + 1
}
infix fun directlyFollows(that: Iterable<FormField>): Boolean {
val lastIndex = that.map { it.index }.max() ?: return false
return index == lastIndex + 1
}
override fun toString(): String {
val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className
val description =
"\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug"
return "$field ($description): password=$passwordCertainty, username=$usernameCertainty"
}
override fun equals(other: Any?): Boolean {
if (other == null) return false
if (this.javaClass != other.javaClass) return false
return autofillId == (other as FormField).autofillId
}
override fun hashCode(): Int {
return autofillId.hashCode()
}
}

View file

@ -0,0 +1,110 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.BuildConfig
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
@RequiresApi(Build.VERSION_CODES.O)
class OreoAutofillService : AutofillService() {
companion object {
// TODO: Provide a user-configurable denylist
private val DENYLISTED_PACKAGES = listOf(
BuildConfig.APPLICATION_ID,
"android",
"com.android.settings",
"com.android.settings.intelligence",
"com.android.systemui",
"com.oneplus.applocker",
"org.sufficientlysecure.keychain"
)
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
}
override fun onCreate() {
super.onCreate()
cachePublicSuffixList(applicationContext)
}
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback
) {
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
callback.onSuccess(null)
return
}
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess(FillResponse.Builder().run {
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
build()
})
} else {
callback.onSuccess(null)
}
return
}
val formToFill = FillableForm.parseAssistStructure(this, structure) ?: run {
d { "Form cannot be filled" }
callback.onSuccess(null)
return
}
formToFill.fillCredentials(this, callback)
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
// we replace it with a wrapper that works the same in all situations.
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
return
}
val clientState = request.clientState ?: run {
e { "Received save request without client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val scenario = AutofillScenario.fromBundle(clientState)?.recoverNodes(structure) ?: run {
e { "Failed to recover client state or nodes from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val formOrigin = FormOrigin.fromBundle(clientState) ?: run {
e { "Failed to recover form origin from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val username = scenario.usernameValue
val password = scenario.passwordValue ?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
return
}
callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender(
this,
credentials = Credentials(username, password),
formOrigin = formOrigin
)
)
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo
import android.content.Context
import kotlinx.coroutines.runBlocking
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
private object PublicSuffixListCache {
private lateinit var publicSuffixList: PublicSuffixList
fun getOrCachePublicSuffixList(context: Context): PublicSuffixList {
if (!::publicSuffixList.isInitialized) {
publicSuffixList = PublicSuffixList(context)
// Trigger loading the actual public suffix list, but don't block.
@Suppress("DeferredResultUnused")
publicSuffixList.prefetch()
}
return publicSuffixList
}
}
fun cachePublicSuffixList(context: Context) {
PublicSuffixListCache.getOrCachePublicSuffixList(context)
}
/**
* Returns the eTLD+1 (also called registrable domain), i.e. the direct subdomain of the public
* suffix of [domain].
*
* Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with
* the return value for valid domains.
*/
fun getPublicSuffixPlusOne(context: Context, domain: String) = runBlocking {
PublicSuffixListCache.getOrCachePublicSuffixList(context).getPublicSuffixPlusOne(domain)
.await() ?: domain
}

View file

@ -0,0 +1,238 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.app.Activity
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.autofill.oreo.AutofillAction
import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
import java.io.UnsupportedEncodingException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.msfjarvis.openpgpktx.util.OpenPgpApi
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
import org.openintents.openpgp.IOpenPgpService2
import org.openintents.openpgp.OpenPgpError
@RequiresApi(Build.VERSION_CODES.O)
class AutofillDecryptActivity : Activity(), CoroutineScope {
companion object {
private const val EXTRA_FILE_PATH = "com.zeapo.pwdstore.autofill.oreo.EXTRA_FILE_PATH"
private const val EXTRA_SEARCH_ACTION =
"com.zeapo.pwdstore.autofill.oreo.EXTRA_SEARCH_ACTION"
private const val REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION = 1
private const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
private var decryptFileRequestCode = 1
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
return Intent(context, AutofillDecryptActivity::class.java).apply {
putExtras(forwardedExtras)
putExtra(EXTRA_SEARCH_ACTION, true)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
}
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
return PendingIntent.getActivity(
context,
decryptFileRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
}
private var continueAfterUserInteraction: Continuation<Intent>? = null
override val coroutineContext
get() = Dispatchers.IO + SupervisorJob()
override fun onStart() {
super.onStart()
val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run {
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
d { action.toString() }
launch {
val credentials = decryptUsernameAndPassword(File(filePath))
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
val fillInDataset =
FillableForm.makeFillInDataset(
this@AutofillDecryptActivity,
credentials,
clientState,
action
)
withContext(Dispatchers.Main) {
setResult(RESULT_OK, Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
})
}
}
withContext(Dispatchers.Main) {
finish()
}
}
}
override fun onDestroy() {
super.onDestroy()
coroutineContext.cancelChildren()
}
private suspend fun executeOpenPgpApi(
data: Intent,
input: InputStream,
output: OutputStream
): Intent? {
var openPgpServiceConnection: OpenPgpServiceConnection? = null
val openPgpService = suspendCoroutine<IOpenPgpService2> { cont ->
openPgpServiceConnection = OpenPgpServiceConnection(
this,
OPENPGP_PROVIDER,
object : OpenPgpServiceConnection.OnBound {
override fun onBound(service: IOpenPgpService2) {
cont.resume(service)
}
override fun onError(e: Exception) {
cont.resumeWithException(e)
}
}).also { it.bindToService() }
}
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
openPgpServiceConnection?.unbindFromService()
}
}
private suspend fun decryptUsernameAndPassword(
file: File,
resumeIntent: Intent? = null
): Credentials? {
val command = resumeIntent ?: Intent().apply {
action = OpenPgpApi.ACTION_DECRYPT_VERIFY
}
val encryptedInput = try {
file.inputStream()
} catch (e: FileNotFoundException) {
e(e) { "File to decrypt not found" }
return null
}
val decryptedOutput = ByteArrayOutputStream()
val result = try {
executeOpenPgpApi(command, encryptedInput, decryptedOutput)
} catch (e: Exception) {
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
return null
}
return when (val resultCode =
result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
try {
val entry = withContext(Dispatchers.IO) {
PasswordEntry(decryptedOutput)
}
Credentials.fromStoreEntry(file, entry)
} catch (e: UnsupportedEncodingException) {
e(e) { "Failed to parse password entry" }
null
}
}
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent =
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)
try {
val intentToResume = withContext(Dispatchers.Main) {
suspendCoroutine<Intent> { cont ->
continueAfterUserInteraction = cont
startIntentSenderForResult(
pendingIntent.intentSender,
REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION,
null,
0,
0,
0
)
}
}
decryptUsernameAndPassword(file, intentToResume)
} catch (e: Exception) {
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
null
}
}
OpenPgpApi.RESULT_CODE_ERROR -> {
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
if (error != null) {
Toast.makeText(
applicationContext,
"Error from OpenKeyChain: ${error.message}",
Toast.LENGTH_LONG
).show()
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
}
null
}
else -> {
e { "Unrecognized OpenPgpApi result: $resultCode" }
null
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE_CONTINUE_AFTER_USER_INTERACTION && continueAfterUserInteraction != null) {
if (resultCode == RESULT_OK && data != null) {
continueAfterUserInteraction?.resume(data)
} else {
continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction"))
}
continueAfterUserInteraction = null
}
}
}

View file

@ -0,0 +1,196 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.annotation.TargetApi
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import com.afollestad.recyclical.datasource.dataSourceOf
import com.afollestad.recyclical.setup
import com.afollestad.recyclical.withItem
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
import java.util.Locale
import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.*
@TargetApi(Build.VERSION_CODES.O)
class AutofillFilterView : AppCompatActivity() {
companion object {
private const val HEIGHT_PERCENTAGE = 0.9
private const val WIDTH_PERCENTAGE = 0.75
private const val DECRYPT_FILL_REQUEST_CODE = 1
private const val EXTRA_FORM_ORIGIN_WEB =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
private const val EXTRA_FORM_ORIGIN_APP =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
private var matchAndDecryptFileRequestCode = 1
fun makeMatchAndDecryptFileIntentSender(
context: Context,
formOrigin: FormOrigin
): IntentSender {
val intent = Intent(context, AutofillFilterView::class.java).apply {
when (formOrigin) {
is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
}
}
return PendingIntent.getActivity(
context,
matchAndDecryptFileRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
}
private val dataSource = dataSourceOf()
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val sortOrder
get() = PasswordRepository.PasswordSortOrder.getSortOrder(preferences)
private lateinit var formOrigin: FormOrigin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_oreo_autofill_filter)
setFinishOnTouchOutside(true)
val params = window.attributes
params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
window.attributes = params
if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
formOrigin = when {
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
}
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
}
else -> {
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
finish()
return
}
}
supportActionBar?.hide()
bindUI()
setResult(RESULT_CANCELED)
}
private fun bindUI() {
// setup is an extension method provided by recyclical
rvPassword.setup {
withDataSource(dataSource)
withItem<PasswordItem, PasswordViewHolder>(R.layout.oreo_autofill_filter_row) {
onBind(::PasswordViewHolder) { _, item ->
title.text = item.fullPathToParent
// drop the .gpg extension
subtitle.text = item.name.dropLast(4)
}
onClick { decryptAndFill(item) }
}
}
rvPassword.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
search.addTextChangedListener { recursiveFilter(it.toString(), strict = false) }
val initialFilter =
formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
search.setText(initialFilter, TextView.BufferType.EDITABLE)
recursiveFilter(initialFilter, strict = formOrigin is FormOrigin.Web)
shouldMatch.text = getString(
R.string.oreo_autofill_match_with,
formOrigin.getPrettyIdentifier(applicationContext)
)
}
private fun decryptAndFill(item: PasswordItem) {
if (shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
if (shouldMatch.isChecked) AutofillMatcher.addMatchFor(
applicationContext,
formOrigin,
item.file
)
// intent?.extras? is checked to be non-null in onCreate
startActivityForResult(
AutofillDecryptActivity.makeDecryptFileIntent(
item.file,
intent!!.extras!!,
this
), DECRYPT_FILL_REQUEST_CODE
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == DECRYPT_FILL_REQUEST_CODE) {
if (resultCode == RESULT_OK) setResult(RESULT_OK, data)
finish()
}
}
private fun recursiveFilter(filter: String, dir: File? = null, strict: Boolean = true) {
val root = PasswordRepository.getRepositoryDirectory(this)
// on the root the pathStack is empty
val passwordItems = if (dir == null) {
PasswordRepository.getPasswords(
PasswordRepository.getRepositoryDirectory(this),
sortOrder
)
} else {
PasswordRepository.getPasswords(
dir,
PasswordRepository.getRepositoryDirectory(this),
sortOrder
)
}
for (item in passwordItems) {
if (item.type == PasswordItem.TYPE_CATEGORY) {
recursiveFilter(filter, item.file, strict = strict)
}
// TODO: Implement fuzzy search if strict == false?
val matches = if (strict) item.file.parentFile.name.let {
it == filter || it.endsWith(".$filter") || it.endsWith("://$filter")
}
else "${item.file.relativeTo(root).path}/${item.file.nameWithoutExtension}".toLowerCase(
Locale.getDefault()
).contains(filter.toLowerCase(Locale.getDefault()))
val inAdapter = dataSource.contains(item)
if (item.type == PasswordItem.TYPE_PASSWORD && matches && !inAdapter) {
dataSource.add(item)
} else if (!matches && inAdapter) {
dataSource.remove(item)
}
}
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.annotation.TargetApi
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPublisherChangedException
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.autofill.oreo.computeCertificatesHash
import kotlinx.android.synthetic.main.activity_oreo_autofill_publisher_changed.*
@TargetApi(Build.VERSION_CODES.O)
class AutofillPublisherChangedActivity : AppCompatActivity() {
companion object {
private const val EXTRA_APP_PACKAGE =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_APP_PACKAGE"
private var publisherChangedRequestCode = 1
fun makePublisherChangedIntentSender(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): IntentSender {
val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply {
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
}
return PendingIntent.getActivity(
context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
}
private lateinit var appPackage: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_oreo_autofill_publisher_changed)
setFinishOnTouchOutside(true)
appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run {
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
finish()
return
}
supportActionBar?.hide()
showPackageInfo()
okButton.setOnClickListener { finish() }
advancedButton.setOnClickListener {
advancedButton.visibility = View.INVISIBLE
warningAppAdvancedInfo.visibility = View.VISIBLE
resetButton.visibility = View.VISIBLE
}
resetButton.setOnClickListener {
AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage))
finish()
}
}
private fun showPackageInfo() {
try {
val packageInfo =
packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
warningAppInstallDate.text =
getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
val appInfo =
packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
warningAppName.text = "${packageManager.getApplicationLabel(appInfo)}"
val currentHash = computeCertificatesHash(this, appPackage)
warningAppAdvancedInfo.text = getString(
R.string.oreo_autofill_warning_publisher_advanced_info_template,
appPackage,
currentHash
)
} catch (exception: Exception) {
e(exception) { "Failed to retrieve package info for $appPackage" }
finish()
}
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.app.Activity
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.PasswordStore
import com.zeapo.pwdstore.autofill.oreo.AutofillAction
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.crypto.PgpActivity
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
class AutofillSaveActivity : Activity() {
companion object {
private const val EXTRA_FOLDER_NAME =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FOLDER_NAME"
private const val EXTRA_PASSWORD = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_PASSWORD"
private const val EXTRA_USERNAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_USERNAME"
private const val EXTRA_SHOULD_MATCH_APP =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
private const val EXTRA_SHOULD_MATCH_WEB =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
private const val EXTRA_GENERATE_PASSWORD =
"com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
private var saveRequestCode = 1
fun makeSaveIntentSender(
context: Context,
credentials: Credentials?,
formOrigin: FormOrigin
): IntentSender {
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
val sanitizedIdentifier = identifier.replace("""[\\\/]""", "")
val folderName =
sanitizedIdentifier.takeUnless { it.isBlank() } ?: formOrigin.identifier
val intent = Intent(context, AutofillSaveActivity::class.java).apply {
putExtras(
bundleOf(
EXTRA_FOLDER_NAME to folderName,
EXTRA_PASSWORD to credentials?.password,
EXTRA_USERNAME to credentials?.username,
EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
EXTRA_GENERATE_PASSWORD to (credentials == null)
)
)
}
return PendingIntent.getActivity(
context,
saveRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
}
private val formOrigin: FormOrigin? by lazy {
val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
if (shouldMatchApp != null && shouldMatchWeb == null) {
FormOrigin.App(shouldMatchApp)
} else if (shouldMatchApp == null && shouldMatchWeb != null) {
FormOrigin.Web(shouldMatchWeb)
} else {
null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repo = PasswordRepository.getRepositoryDirectory(applicationContext)
val username = intent.getStringExtra(EXTRA_USERNAME)
val saveIntent = Intent(this, PgpActivity::class.java).apply {
putExtras(
bundleOf(
"REPO_PATH" to repo.absolutePath,
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)).absolutePath,
"OPERATION" to "ENCRYPT",
"SUGGESTED_NAME" to username,
"SUGGESTED_PASS" to intent.getStringExtra(EXTRA_PASSWORD),
"GENERATE_PASSWORD" to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
)
)
}
startActivityForResult(saveIntent, PasswordStore.REQUEST_CODE_ENCRYPT)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == PasswordStore.REQUEST_CODE_ENCRYPT && resultCode == RESULT_OK && data != null) {
val createdPath = data.getStringExtra("CREATED_FILE")
if (createdPath != null) {
formOrigin?.let {
AutofillMatcher.addMatchFor(this, it, File(createdPath))
}
}
val password = data.getStringExtra("PASSWORD")
val username = data.getStringExtra("USERNAME")
if (password != null) {
val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
val credentials = Credentials(username, password)
val fillInDataset = FillableForm.makeFillInDataset(
this,
credentials,
clientState,
AutofillAction.Generate
)
setResult(RESULT_OK, Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
})
}
}
finish()
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.zeapo.pwdstore.R
class PasswordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val title: TextView = itemView.findViewById(R.id.title)
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
}

View file

@ -16,6 +16,7 @@ import android.content.SharedPreferences
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.text.TextUtils
import android.text.format.DateUtils
import android.text.method.PasswordTransformationMethod
@ -30,6 +31,7 @@ import android.widget.CheckBox
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
@ -48,11 +50,7 @@ import java.io.File
import java.nio.charset.Charset
import java.util.Date
import kotlinx.android.synthetic.main.decrypt_layout.*
import kotlinx.android.synthetic.main.encrypt_layout.crypto_extra_edit
import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_category
import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_edit
import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_file_edit
import kotlinx.android.synthetic.main.encrypt_layout.generate_password
import kotlinx.android.synthetic.main.encrypt_layout.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import me.msfjarvis.openpgpktx.util.OpenPgpApi
@ -81,6 +79,11 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
private var editPass: String? = null
private var editExtra: String? = null
private val suggestedName by lazy { intent.getStringExtra("SUGGESTED_NAME") }
private val suggestedPass by lazy { intent.getStringExtra("SUGGESTED_PASS") }
private val suggestedExtra by lazy { intent.getStringExtra("SUGGESTED_EXTRA") }
private val shouldGeneratePassword by lazy { intent.getBooleanExtra("GENERATE_PASSWORD", false) }
private val operation: String by lazy { intent.getStringExtra("OPERATION") }
private val repoPath: String by lazy { intent.getStringExtra("REPO_PATH") }
@ -150,18 +153,69 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
setContentView(R.layout.encrypt_layout)
generate_password?.setOnClickListener {
when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) {
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
.show(supportFragmentManager, "generator")
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
.show(supportFragmentManager, "xkpwgenerator")
}
generatePassword()
}
title = getString(R.string.new_password_title)
crypto_password_category.text = getRelativePath(fullPath, repoPath)
suggestedName?.let {
crypto_password_file_edit.setText(it)
encrypt_username.apply {
visibility = View.VISIBLE
setOnClickListener {
if (isChecked) {
// User wants to enable username encryption, so we add it to the
// encrypted extras as the first line.
val username = crypto_password_file_edit.text!!.toString()
val extras = "username:$username\n${crypto_extra_edit.text!!}"
crypto_password_file_edit.setText("")
crypto_extra_edit.setText(extras)
} else {
// User wants to disable username encryption, so we extract the
// username from the encrypted extras and use it as the filename.
val entry = PasswordEntry("PASSWORD\n${crypto_extra_edit.text!!}")
val username = entry.username
// username should not be null here by the logic in
// updateEncryptUsernameState, but it could still happen due to
// input lag.
if (username != null) {
crypto_password_file_edit.setText(username)
crypto_extra_edit.setText(entry.extraContentWithoutUsername)
}
}
updateEncryptUsernameState()
}
}
crypto_password_file_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() }
crypto_extra_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() }
updateEncryptUsernameState()
}
suggestedPass?.let {
crypto_password_edit.setText(it)
crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
suggestedExtra?.let { crypto_extra_edit.setText(it) }
if (shouldGeneratePassword) {
generatePassword()
crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
}
}
fun updateEncryptUsernameState() {
encrypt_username.apply {
if (visibility != View.VISIBLE)
return
val hasUsernameInFileName = crypto_password_file_edit.text!!.toString().isNotBlank()
// Use PasswordEntry to parse extras for username
val entry = PasswordEntry("PLACEHOLDER\n${crypto_extra_edit.text!!}")
val hasUsernameInExtras = entry.hasUsername()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
isChecked = hasUsernameInExtras
}
}
override fun onResume() {
@ -169,6 +223,15 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_CLEAR))
}
private fun generatePassword() {
when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) {
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
.show(supportFragmentManager, "generator")
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
.show(supportFragmentManager, "xkpwgenerator")
}
}
override fun onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
super.onStop()
@ -482,7 +545,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
data.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
// TODO Check if we could use PasswordEntry to generate the file
val iStream = ByteArrayInputStream("$editPass\n$editExtra".toByteArray(Charset.forName("UTF-8")))
val content = "$editPass\n$editExtra"
val iStream = ByteArrayInputStream(content.toByteArray(Charset.forName("UTF-8")))
val oStream = ByteArrayOutputStream()
val path = if (intent.getBooleanExtra("fromDecrypt", false)) fullPath else "$fullPath/$editName.gpg"
@ -494,7 +558,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
RESULT_CODE_SUCCESS -> {
try {
// TODO This might fail, we should check that the write is successful
val outputStream = FileUtils.openOutputStream(File(path))
val file = File(path)
val outputStream = FileUtils.openOutputStream(file)
outputStream.write(oStream.toByteArray())
outputStream.close()
@ -508,6 +573,13 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
returnIntent.putExtra("OPERATION", "EDIT")
returnIntent.putExtra("needCommit", true)
}
if (shouldGeneratePassword) {
val entry = PasswordEntry(content)
returnIntent.putExtra("PASSWORD", entry.password)
returnIntent.putExtra("USERNAME", entry.username ?: file.nameWithoutExtension)
}
setResult(RESULT_OK, returnIntent)
finish()
} catch (e: Exception) {

View file

@ -5,7 +5,10 @@
package com.zeapo.pwdstore.utils
import android.content.Context
import android.os.Build
import android.util.TypedValue
import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi
fun String.splitLines(): Array<String> {
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
@ -16,3 +19,7 @@ fun Context.resolveAttribute(attr: Int): Int {
this.theme.resolveAttribute(attr, typedValue, true)
return typedValue.data
}
val Context.autofillManager: AutofillManager?
@RequiresApi(Build.VERSION_CODES.O)
get() = getSystemService(AutofillManager::class.java)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M14,2H6A2,2 0,0 0,4 4V20A2,2 0,0 0,6 22H18A2,2 0,0 0,20 20V8L14,2M18,20H6V4H13V9H18M12.83,15A3,3 0,1 0,12.83 17H14V19H16V17H17V15M10,17A1,1 0,1 1,11 16A1,1 0,0 1,10 17Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFF0000"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="2dp"
tools:context="com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView">
<ImageView
android:id="@+id/cover"
android:layout_width="0dp"
android:layout_height="50dp"
android:background="@color/primary_color"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintBottom_toTopOf="@id/searchLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/searchLayout"
style="@style/TextInputLayoutBase"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
app:endIconMode="clear_text"
app:layout_constraintBottom_toTopOf="@id/rvPassword"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cover">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/action_search"
android:imeOptions="actionDone"
android:inputType="text"
tools:text="example.com" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvPassword"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:scrollbars="vertical"
app:layout_constraintBottom_toTopOf="@id/shouldMatch"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchLayout"
tools:itemCount="5"
tools:listitem="@layout/password_row_layout" />
<Switch
android:id="@+id/shouldMatch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:checked="true"
app:layout_constraintBottom_toTopOf="@id/shouldClear"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rvPassword"
app:layout_constraintVertical_bias="1.0"
tools:text="Match with example.org" />
<Switch
android:id="@+id/shouldClear"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/activity_vertical_margin"
android:text="@string/oreo_autofill_matches_clear_existing"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/shouldMatch"
app:layout_constraintVertical_bias="1.0" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,167 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="280dp"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:elevation="2dp"
android:scrollbars="vertical"
tools:context="com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity">
<ImageView
android:id="@+id/cover"
android:layout_width="0dp"
android:layout_height="50dp"
android:background="@color/primary_color"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintBottom_toTopOf="@id/warningSign"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintVertical_chainStyle="packed" />
<ImageView
android:id="@+id/warningSign"
android:layout_width="0dp"
android:layout_height="50dp"
android:background="@color/window_background"
android:contentDescription="@string/oreo_autofill_warning_publisher_warning_sign_description"
android:src="@drawable/ic_warning_red_24dp"
app:layout_constraintBottom_toTopOf="@id/warningHeader"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cover" />
<TextView
android:id="@+id/warningHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/activity_vertical_margin"
android:background="@color/window_background"
android:text="@string/oreo_autofill_warning_publisher_header"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/warningAppName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/warningSign" />
<TextView
android:id="@+id/warningAppName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:background="@color/window_background"
android:gravity="center_horizontal"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/warningAppInstallDate"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/warningHeader"
tools:text="Example Banking" />
<TextView
android:id="@+id/warningAppInstallDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:background="@color/window_background"
android:gravity="center_horizontal"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/warningAppFooter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/warningAppName"
tools:text="Installed: 5 days ago" />
<TextView
android:id="@+id/warningAppFooter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/activity_vertical_margin"
android:background="@color/window_background"
android:text="@string/oreo_autofill_warning_publisher_footer"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/okButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/warningAppInstallDate" />
<Button
android:id="@+id/okButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:text="@string/oreo_autofill_warning_publisher_changed_disable_autofill_button"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/advancedButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/warningAppFooter" />
<Button
android:id="@+id/advancedButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:text="@string/oreo_autofill_warning_publisher_advanced_info_button"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/warningAppAdvancedInfo"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/okButton" />
<TextView
android:id="@+id/warningAppAdvancedInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:background="@color/window_background"
android:fontFamily="monospace"
android:gravity="center_horizontal"
android:textIsSelectable="true"
android:textSize="10sp"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/resetButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/advancedButton"
tools:ignore="SmallSp"
tools:text="Package: com.example.banking\n\nHash:\n8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M="
tools:visibility="visible" />
<Button
android:id="@+id/resetButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:text="@string/oreo_autofill_warning_publisher_reenable_button"
android:textSize="10sp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/warningAppAdvancedInfo"
tools:ignore="SmallSp"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -45,11 +45,13 @@
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/crypto_pass_label"
app:endIconMode="password_toggle"
app:hintEnabled="true"
app:layout_constraintTop_toBottomOf="@id/name_input_layout"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/crypto_password_edit"
android:inputType="textVisiblePassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</com.google.android.material.textfield.TextInputLayout>
@ -84,4 +86,15 @@
android:layout_height="wrap_content"/>
</com.google.android.material.textfield.TextInputLayout>
<Switch
android:id="@+id/encrypt_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/crypto_encrypt_username_label"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/extra_input_layout"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingLeft="10dp"
android:paddingTop="5dp"
android:paddingRight="10dp"
android:paddingBottom="5dp">
<ImageView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginEnd="10dp"
android:adjustViewBounds="true"
android:maxWidth="20dp"
android:maxHeight="20dp"
tools:src="@mipmap/ic_launcher" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
android:textStyle="bold"
tools:text="example.org" />
<TextView
android:id="@+id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
tools:text="john@doe.org" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<com.zeapo.pwdstore.widget.MultiselectableConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/password_row_background"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/credentialIcon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:alpha="0.5"
android:src="@drawable/ic_person_black_24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/title"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="middle"
android:singleLine="true"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@id/subtitle"
app:layout_constraintEnd_toStartOf="@id/continueIcon"
app:layout_constraintStart_toEndOf="@id/credentialIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="/example.com/" />
<TextView
android:id="@+id/subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="middle"
android:singleLine="true"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/continueIcon"
app:layout_constraintStart_toEndOf="@id/credentialIcon"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="johndoe@example.com" />
<ImageView
android:id="@+id/continueIcon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_keyboard_arrow_right_24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@id/title"
app:layout_constraintTop_toTopOf="parent" />
</com.zeapo.pwdstore.widget.MultiselectableConstraintLayout>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="20dp"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="20dp">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Password Store can offer to fill login forms and even save credentials you enter in apps or on websites."
android:textSize="16sp" />
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="To enable this feature, tap OK to go to Autofill settings. There, select Password Store from the list."
android:textSize="16sp" />
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Autofill support with installed browsers:"
android:textSize="16sp" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/supportedBrowsers"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Firefox: Fill and save credentials"
android:textSize="16sp" />
</LinearLayout>
</ScrollView>

View file

@ -8,7 +8,7 @@
android:paddingTop="12dp"
android:paddingBottom="12dp">
<androidx.appcompat.widget.AppCompatImageView
<ImageView
android:id="@+id/type_image"
android:layout_width="60dp"
android:layout_height="32dp"
@ -20,7 +20,7 @@
app:layout_constraintBottom_toBottomOf="parent"
tools:src="@drawable/ic_multiple_files_24dp" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -31,7 +31,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="FILE_NAME" />
<androidx.appcompat.widget.AppCompatTextView
<TextView
android:id="@+id/child_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -42,7 +42,7 @@
android:layout_marginEnd="12dp"
tools:text="12" />
<androidx.appcompat.widget.AppCompatImageView
<ImageView
android:id="@+id/folder_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -100,6 +100,7 @@
<string name="crypto_name_hint">Name</string>
<string name="crypto_pass_label">Password</string>
<string name="crypto_extra_label">Extra content</string>
<string name="crypto_encrypt_username_label">Encrypt username</string>
<string name="crypto_select">Select</string>
<string name="crypto_cancel">Cancel</string>
<string name="crypto_save">Save</string>
@ -151,7 +152,7 @@
<string name="pref_file_first_sort_order">Files first</string>
<string name="pref_type_independent_sort_order">Type independent</string>
<string name="pref_autofill_title">Autofill</string>
<string name="pref_autofill_enable_title">Enable autofill</string>
<string name="pref_autofill_enable_title">Enable Autofill</string>
<string name="pref_autofill_enable_msg">Tap OK to go to Accessibility settings. There, tap Password Store under Services then tap the switch in the top right to turn it on or off.</string>
<string name="pref_autofill_enable_msg2">Once the service is on, a dialog will appear when you click on a password field in an app if a matching password for the app exists.</string>
<string name="pref_autofill_enable_msg3">Password Store attempts to match apps with passwords automatically. You can change this default setting and also matching settings per-app.</string>
@ -242,6 +243,32 @@
<string name="app_icon_hint">App icon</string>
<string name="folder_icon_hint">Folder icon</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_match_with">Match with %1$s</string>
<string name="oreo_autofill_matches_clear_existing">Clear existing matches</string>
<string name="oreo_autofill_search_in_store">Search in store…</string>
<string name="oreo_autofill_save_internal_error">Save failed due to an internal error</string>
<string name="oreo_autofill_save_app_not_supported">This app is currently not supported</string>
<string name="oreo_autofill_save_passwords_dont_match">Passwords don\'t match</string>
<string name="oreo_autofill_save_invalid_password">Couldn\'t extract password, please use a different browser for now</string>
<string name="oreo_autofill_generate_password">Generate password…</string>
<string name="oreo_autofill_publisher_changed">The app\'s publisher has changed; this may be a phishing attempt.</string>
<string name="oreo_autofill_max_matches_reached">Maximum number of matches (%1$d) reached; clear matches before adding new ones.</string>
<string name="oreo_autofill_warning_publisher_header">This app\'s publisher has changed since you first associated a Password Store entry with it:</string>
<string name="oreo_autofill_warning_publisher_footer"><b>The currently installed app may be trying to steal your credentials by pretending to be a trusted app.</b>\n\nTry to uninstall and reinstall the app from a trusted source, such as the Play Store, Amazon Appstore, F-Droid, or your phone manufacturer\'s store.</string>
<string name="oreo_autofill_warning_publisher_install_time">Installed: %1$s</string>
<string name="oreo_autofill_warning_publisher_advanced_info_template" translatable="false">Package:\n%1$s\n\nHash:\n%2$s</string>
<string name="oreo_autofill_warning_publisher_advanced_info_button">Advanced information</string>
<string name="oreo_autofill_warning_publisher_changed_disable_autofill_button">Keep Autofill disabled</string>
<string name="oreo_autofill_warning_publisher_reenable_button">Re-enable Autofill</string>
<string name="oreo_autofill_warning_publisher_warning_sign_description">Warning</string>
<string name="oreo_autofill_warning_publisher_dataset_summary">Tap for details…</string>
<string name="oreo_autofill_warning_publisher_dataset_title">Possible phishing attempt</string>
<string name="oreo_autofill_fill_and_save_support">Fill and save credentials</string>
<string name="oreo_autofill_fill_support">Fill credentials</string>
<string name="oreo_autofill_flaky_fill_support">Fill credentials (may require restarting the browser from time to time)</string>
<string name="oreo_autofill_no_support">No support</string>
<!-- Autofill -->
<string name="autofill_description">Autofills password fields in apps. Only works for Android versions 4.3 and up. Does not rely on the clipboard for Android versions 5.0 and up.</string>
<string name="autofill_apps_default">Use default setting</string>

View file

@ -36,4 +36,12 @@
<item name="boxStrokeColor">?attr/colorSecondary</item>
<item name="hintTextColor">?attr/colorOnPrimary</item>
</style>
<style name="DialogLikeTheme" parent="AppTheme">
<item name="android:windowActionBar">false</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
<!-- Needs to be set without android: prefix as per https://stackoverflow.com/a/33417456 -->
<item name="windowNoTitle">true</item>
</style>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android">
<compatibility-package android:name="com.android.chrome" />
<compatibility-package android:name="com.brave.browser" />
<compatibility-package android:name="com.chrome.beta" />
<compatibility-package android:name="com.chrome.canary" />
<compatibility-package android:name="com.chrome.dev" />
<compatibility-package android:name="com.microsoft.emmx" />
<compatibility-package android:name="com.opera.mini.native" />
<compatibility-package android:name="com.opera.mini.native.beta" />
<compatibility-package android:name="org.mozilla.fennec_fdroid" />
<compatibility-package android:name="org.mozilla.firefox" />
<compatibility-package android:name="org.mozilla.firefox_beta" />
</autofill-service>

View file

@ -1,6 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.preference.PreferenceCategory app:title="@string/pref_autofill_title">
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="true"
app:key="autofill_enable"
app:title="@string/pref_autofill_enable_title"/>
<androidx.preference.Preference
app:key="autofill_apps"
app:title="@string/pref_autofill_apps_title"/>
<androidx.preference.CheckBoxPreference
app:defaultValue="true"
app:key="autofill_default"
app:summary="@string/pref_autofill_default_hint"
app:title="@string/pref_autofill_default_title"/>
<androidx.preference.CheckBoxPreference
app:defaultValue="false"
app:key="autofill_always"
app:title="@string/pref_autofill_always_title"/>
<androidx.preference.CheckBoxPreference
app:defaultValue="false"
app:key="autofill_full_path"
app:summary="@string/pref_autofill_full_path_hint"
app:title="@string/pref_autofill_full_path_title"/>
</androidx.preference.PreferenceCategory>
<androidx.preference.PreferenceCategory app:title="@string/pref_git_title">
<androidx.preference.Preference
app:key="git_server_info"
@ -134,30 +158,6 @@
app:summary="@string/biometric_auth_summary" />
</androidx.preference.PreferenceCategory>
<androidx.preference.PreferenceCategory app:title="@string/pref_autofill_title">
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="true"
app:key="autofill_enable"
app:title="@string/pref_autofill_enable_title"/>
<androidx.preference.Preference
app:key="autofill_apps"
app:title="@string/pref_autofill_apps_title"/>
<androidx.preference.CheckBoxPreference
app:defaultValue="true"
app:key="autofill_default"
app:summary="@string/pref_autofill_default_hint"
app:title="@string/pref_autofill_default_title"/>
<androidx.preference.CheckBoxPreference
app:defaultValue="false"
app:key="autofill_always"
app:title="@string/pref_autofill_always_title"/>
<androidx.preference.CheckBoxPreference
app:defaultValue="false"
app:key="autofill_full_path"
app:summary="@string/pref_autofill_full_path_hint"
app:title="@string/pref_autofill_full_path_title"/>
</androidx.preference.PreferenceCategory>
<androidx.preference.PreferenceCategory app:title="@string/pref_misc_title">
<androidx.preference.Preference
app:key="export_passwords"

View file

@ -25,7 +25,12 @@ subprojects {
repositories {
google()
jcenter()
maven { url 'https://jitpack.io' }
maven {
url 'https://jitpack.io'
}
maven {
url 'https://maven.mozilla.org/maven2'
}
}
pluginManager.withPlugin('kotlin-android') {
dependencies {

View file

@ -50,8 +50,15 @@ ext.deps = [
jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r',
leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.2',
openpgp_ktx: 'com.github.android-password-store:openpgp-ktx:1.2.0',
// The library is updated every two weeks to include the most recent version of the Public
// suffix list. Its API is expected to remain stable for the foreseeable future, and thus
// a reference to the latest version is warranted.
// See: https://github.com/mozilla-mobile/android-components/blob/master/components/lib/publicsuffixlist/README.md
publicsuffixlist: 'org.mozilla.components:lib-publicsuffixlist:+',
recyclical: 'com.afollestad:recyclical:1.1.1',
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
timber: 'com.jakewharton.timber:timber:4.7.1',
timberkt: 'com.github.ajalt:timberkt:1.5.1',
whatthestack: 'com.github.haroldadmin:WhatTheStack:0.0.1',
],

5
scripts/hash_browser_app.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
PACKAGE_NAME="$(aapt dump badging "$1" | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8-)"
HASH="$(apksigner verify --print-certs "$1" | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64)"
echo "\"$PACKAGE_NAME\" to \"$HASH\""