diff options
author | Vincent Breitmoser <look@my.amazin.horse> | 2020-09-16 20:17:55 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-09-16 23:47:55 +0530 |
commit | 08102734445257f9bbd4a7477c49e9e55fd88eb2 (patch) | |
tree | 2e9170d8483e7b5f1cb440d44e67d3dd42221f97 /app/src/main/java | |
parent | 4ba3b75f858251099f18f07be700f9900128f8e1 (diff) |
Autofill: Extract AutofillParser into separate subproject (#1101)
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Fabian Henneke <fabian@henneke.me>
Diffstat (limited to 'app/src/main/java')
23 files changed, 318 insertions, 2584 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 4e4bf85e..eb339ac3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -35,6 +35,8 @@ import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.i import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel import com.github.michaelbull.result.fold import com.github.michaelbull.result.getOr import com.github.michaelbull.result.onFailure @@ -43,8 +45,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher -import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel -import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName import com.zeapo.pwdstore.crypto.DecryptActivity import com.zeapo.pwdstore.crypto.PasswordCreationActivity diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index d41a7fe7..7aeec148 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -36,13 +36,13 @@ import androidx.preference.SwitchPreferenceCompat import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel import com.github.michaelbull.result.getOr import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.BasePgpActivity import com.zeapo.pwdstore.git.GitConfigActivity import com.zeapo.pwdstore.git.GitServerConfigActivity diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt deleted file mode 100644 index 502c9423..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt +++ /dev/null @@ -1,210 +0,0 @@ -/* - * 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.annotation.SuppressLint -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.R -import com.zeapo.pwdstore.model.PasswordEntry -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -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 Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) - -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 { - // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it - // no longer applies to Android 4.4+. - // Even though there is a new way to get the certificates as of Android Pie, we need to keep - // hashes comparable between versions and hence default to using the deprecated API. - @SuppressLint("PackageManagerGetSignatures") - @Suppress("DEPRECATION") - 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?, val otp: String?) { - companion object { - - fun fromStoreEntry( - context: Context, - file: File, - entry: PasswordEntry, - directoryStructure: DirectoryStructure - ): Credentials { - // Always give priority to a username stored in the encrypted extras - val username = entry.username - ?: directoryStructure.getUsernameFor(file) - ?: context.getDefaultUsername() - return Credentials(username, entry.password, entry.calculateTotpCode()) - } - } -} - -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 directoryStructure = AutofillPreferences.directoryStructure(context) - val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) - val summary = directoryStructure.getUsernameFor(relativeFile) - ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: "" - 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 makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms) - val iconRes = R.drawable.ic_autofill_sms - 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 -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt index 1b0ff35d..601be34a 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt @@ -14,6 +14,8 @@ import com.github.ajalt.timberkt.w import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash import com.zeapo.pwdstore.R import java.io.File diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt index 9c84e1ad..cc0875f3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt @@ -7,6 +7,8 @@ package com.zeapo.pwdstore.autofill.oreo import android.content.Context import android.os.Build import androidx.annotation.RequiresApi +import com.github.androidpasswordstore.autofillparser.Credentials +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.sharedPrefs import java.io.File import java.nio.file.Paths @@ -121,4 +123,17 @@ object AutofillPreferences { val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null) return DirectoryStructure.fromValue(value) } + + fun credentialsFromStoreEntry( + context: Context, + file: File, + entry: PasswordEntry, + directoryStructure: DirectoryStructure + ): Credentials { + // Always give priority to a username stored in the encrypted extras + val username = entry.username + ?: directoryStructure.getUsernameFor(file) + ?: context.getDefaultUsername() + return Credentials(username, entry.password, entry.calculateTotpCode()) + } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt new file mode 100644 index 00000000..0e73b908 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt @@ -0,0 +1,192 @@ +/* + * 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.IntentSender +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.widget.RemoteViews +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.fold +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +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 com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillResponseBuilder(form: FillableForm) { + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // 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 makeFillOtpFromSmsDataset(context: Context): Dataset? { + if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms) + } + + 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) + } + makeFillOtpFromSmsDataset(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) { + AutofillMatcher.getMatchesFor(context, formOrigin).fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, matchedFiles)) + }, + failure = { e -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, e)) + } + ) + } + + companion object { + fun makeFillInDataset( + context: Context, + credentials: Credentials, + clientState: Bundle, + action: AutofillAction + ): Dataset { + val remoteView = makePlaceholderRemoteView(context) + val scenario = AutofillScenario.fromBundle(clientState) + // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even + // though they are never shown. + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Dataset.Builder() + } else { + Dataset.Builder(remoteView) + } + return builder.run { + if (scenario != null) fillWith(scenario, action, credentials) + else e { "Failed to recover scenario from client state" } + build() + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt deleted file mode 100644 index f4fd5cf1..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillScenario.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * 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 -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching - -enum class AutofillAction { - Match, Search, Generate, FillOtpFromSms -} - -/** - * 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_OTP_ID = "otpId" - 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 runCatching { - Builder<AutofillId>().apply { - username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID) - fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME) - otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID) - 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() - }.getOrElse { e -> - e(e) - null - } - } - } - - class Builder<T : Any> { - - var username: T? = null - var fillUsername = false - var otp: T? = null - 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, - otp = otp, - currentPassword = currentPassword, - newPassword = newPassword - ) - } else { - GenericAutofillScenario( - username = username, - fillUsername = fillUsername, - otp = otp, - genericPassword = genericPassword - ) - } - } - } - - abstract val username: T? - abstract val fillUsername: Boolean - abstract val otp: T? - 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, otp) + allPasswordFields - - fun fieldsToFillOn(action: AutofillAction): List<T> { - val credentialFieldsToFill = when (action) { - AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp) - AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp) - AutofillAction.Generate -> passwordFieldsToFillOnGenerate - AutofillAction.FillOtpFromSms -> listOfNotNull(otp) - } - return when { - action == AutofillAction.FillOtpFromSms -> { - // When filling from an SMS, we cannot get any data other than the OTP itself. - credentialFieldsToFill - } - credentialFieldsToFill.isNotEmpty() -> { - // If the current action would fill into any password field, we also fill into the - // username field if possible. - listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill - } - 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, - override val otp: T?, - 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, - override val otp: T?, - 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", - "OTP" - ) - for (field in scenario.fieldsToFillOn(action)) { - val value = when (field) { - scenario.username -> credentialsToFill.username - scenario.otp -> credentialsToFill.otp - else -> credentialsToFill.password - } - 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 - builder.otp = otp?.let(transform) - 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(5).apply { - putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) - putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) - putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) - putParcelableArrayList( - AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword) - ) - putParcelableArrayList( - AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword) - ) - } - } - is GenericAutofillScenario<AutofillId> -> { - Bundle(4).apply { - putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username) - putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername) - putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp) - 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() - } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt deleted file mode 100644 index 90bb7051..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategy.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * 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 { hasHintNewPassword } } - breakTieOnPair { any { isFocused } } - } - currentPassword(optional = true) { - takeSingle { alreadyMatched -> - val adjacentToNewPasswords = - directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched) - // The Autofill framework has not hint that applies to current passwords only. - // In this scenario, we have already matched fields a pair of fields with a specific - // new password hint, so we take a generic Autofill password hint to mean a current - // password. - (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && - adjacentToNewPasswords - } - } - username(optional = true) { - takeSingle { hasHintUsername } - 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 { _ -> - 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 { hasHintUsername } - 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 { 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 { 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 { hasHintNewPassword && 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 { hasHintUsername && isFocused } - } - currentPassword(matchHidden = true) { - takeSingle { alreadyMatched -> - directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword - } - } - } - - // Match a single focused OTP field. - rule(applyInSingleOriginMode = true) { - otp { - takeSingle { otpCertainty >= Likely && isFocused } - } - } - - // Match a single focused username field without a password field. - rule(applyInSingleOriginMode = true) { - username { - takeSingle { usernameCertainty >= Likely && isFocused } - breakTieOnSingle { usernameCertainty >= Certain } - breakTieOnSingle { hasHintUsername } - } - } - - // Match any focused password field with optional username field on manual request. - rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { - genericPassword { - takeSingle { isFocused } - } - username(optional = true) { - takeSingle { alreadyMatched -> - usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) - } - } - } - - // Match any focused username field on manual request. - rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { - username { - takeSingle { isFocused } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt deleted file mode 100644 index cae84d54..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillStrategyDsl.kt +++ /dev/null @@ -1,381 +0,0 @@ -/* - * 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 applyOnManualRequestOnly: Boolean, - private val name: String -) { - - data class AutofillRuleMatcher( - val type: FillableFieldType, - val matcher: FieldMatcher, - val optional: Boolean, - val matchHidden: Boolean - ) - - enum class FillableFieldType { - Username, Otp, CurrentPassword, NewPassword, GenericPassword, - } - - @AutofillDsl - class Builder( - private val applyInSingleOriginMode: Boolean, - private val applyOnManualRequestOnly: 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 otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) { - require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" } - matchers.add( - AutofillRuleMatcher( - type = FillableFieldType.Otp, - matcher = SingleFieldMatcher.Builder().apply(block).build(), - optional = optional, - matchHidden = false - ) - ) - } - - 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, applyOnManualRequestOnly, name ?: "Rule #$ruleId" - ).also { ruleId++ } - } - } - - fun match( - allPassword: List<FormField>, - allUsername: List<FormField>, - allOtp: List<FormField>, - singleOriginMode: Boolean, - isManualRequest: Boolean - ): AutofillScenario<FormField>? { - if (singleOriginMode && !applyInSingleOriginMode) { - d { "$name: Skipped in single origin mode" } - return null - } - if (!isManualRequest && applyOnManualRequestOnly) { - d { "$name: Skipped since not a manual request" } - 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 - FillableFieldType.Otp -> allOtp - 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.Otp -> { - check(matchResult.size == 1 && scenarioBuilder.otp == null) - scenarioBuilder.otp = matchResult.single() - } - 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, - applyOnManualRequestOnly: Boolean = false, - block: AutofillRule.Builder.() -> Unit - ) { - rules.add( - AutofillRule.Builder( - applyInSingleOriginMode = applyInSingleOriginMode, - applyOnManualRequestOnly = applyOnManualRequestOnly - ).apply(block).build() - ) - } - - fun build() = AutofillStrategy(rules) - } - - fun match( - fields: List<FormField>, - singleOriginMode: Boolean, - isManualRequest: 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}" } - val possibleOtpFields = - fields.filter { it.otpCertainty >= CertaintyLevel.Possible } - d { "Possible otp fields: ${possibleOtpFields.size}" } - // Return the result of the first rule that matches - d { "Rules: ${rules.size}" } - for (rule in rules) { - return rule.match( - possiblePasswordFields, - possibleUsernameFields, - possibleOtpFields, - singleOriginMode = singleOriginMode, - isManualRequest = isManualRequest - ) - ?: continue - } - return null - } -} - -fun strategy(block: AutofillStrategy.Builder.() -> Unit) = - AutofillStrategy.Builder().apply(block).build() diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt new file mode 100644 index 00000000..5e8061a2 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt @@ -0,0 +1,67 @@ +/* + * 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.widget.RemoteViews +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.PasswordRepository +import java.io.File + +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 directoryStructure = AutofillPreferences.directoryStructure(context) + val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) + val summary = directoryStructure.getUsernameFor(relativeFile) + ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: "" + 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 makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { + val title = formOrigin.getPrettyIdentifier(context, untrusted = true) + val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms) + val iconRes = R.drawable.ic_autofill_sms + 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) +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt deleted file mode 100644 index e49f7e81..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FeatureAndTrustDetection.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * 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.bromite.bromite" to "4e5c0HbXsNyEyytF+3i4bfLrOaO2xWuj3CkqXgw7lQQ=", - "org.gnu.icecat" to "wi2iuVvK/WYZUzd2g0Qzn9ef3kAisQURZ8U1WSMTkcM=", - "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=", - "org.ungoogled.chromium.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", - "org.ungoogled.chromium.extensions.stable" to "29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk=", - "com.kiwibrowser.browser" to "wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig=", -) - -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.gnu.icecat" 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 saveFlags: Int? -) - -@RequiresApi(Build.VERSION_CODES.O) -fun getBrowserAutofillSupportInfoIfTrusted( - context: Context, - appPackage: String -): BrowserAutofillSupportInfo? { - if (!isTrustedBrowser(context, appPackage)) return null - return BrowserAutofillSupportInfo( - multiOriginMethod = getBrowserMultiOriginMethod(appPackage), - saveFlags = getBrowserSaveFlag(appPackage) - ) -} - -private val FLAKY_BROWSERS = listOf( - "com.android.chrome", - "com.chrome.beta", - "com.chrome.canary", - "com.chrome.dev", - "org.bromite.bromite", - "org.ungoogled.chromium.stable", - "com.kiwibrowser.browser", -) - -enum class BrowserAutofillSupportLevel { - None, - FlakyFill, - PasswordFill, - GeneralFill, - GeneralFillAndSave, -} - -@RequiresApi(Build.VERSION_CODES.O) -private fun getBrowserAutofillSupportLevel( - context: Context, - appPackage: String -): BrowserAutofillSupportLevel { - val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage) - return when { - browserInfo == null -> BrowserAutofillSupportLevel.None - appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill - browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill - browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill - else -> BrowserAutofillSupportLevel.GeneralFillAndSave - } -} - -@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 - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt deleted file mode 100644 index 5d24c882..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt +++ /dev/null @@ -1,383 +0,0 @@ -/* - * 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.github.michaelbull.result.fold -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 com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity -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, isManualRequest: Boolean) { - - 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 trustedBrowserInfo = - getBrowserAutofillSupportInfoIfTrusted(context, appPackage) - val saveFlags = trustedBrowserInfo?.saveFlags - - private val webOrigins = mutableSetOf<String>() - - init { - d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" } - parseStructure(structure) - } - - val scenario = detectFieldsToFill(isManualRequest) - 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 (trustedBrowserInfo?.multiOriginMethod == 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(isManualRequest: Boolean) = autofillStrategy.match( - relevantFields, - singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None, - isManualRequest = isManualRequest - ) - - private fun trackOrigin(node: AssistStructure.ViewNode) { - if (trustedBrowserInfo == null) 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 (trustedBrowserInfo == null || 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 (trustedBrowserInfo.multiOriginMethod) { - 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) - // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are never shown. - val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - Dataset.Builder() - } else { - Dataset.Builder(remoteView) - } - return builder.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, - isManualRequest: Boolean - ): FillableForm? { - val form = Form(context, structure, isManualRequest) - 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 makeFillOtpFromSmsDataset(context: Context): Dataset? { - if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null - if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin) - val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms) - } - - 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) - } - makeFillOtpFromSmsDataset(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) { - AutofillMatcher.getMatchesFor(context, formOrigin).fold( - success = { matchedFiles -> - callback.onSuccess(makeFillResponse(context, matchedFiles)) - }, - failure = { e -> - e(e) - callback.onSuccess(makePublisherChangedResponse(context, e)) - } - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt deleted file mode 100644 index 6435a83f..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/FormField.kt +++ /dev/null @@ -1,303 +0,0 @@ -/* - * 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 androidx.autofill.HintConstants -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 { - - private val HINTS_USERNAME = listOf( - HintConstants.AUTOFILL_HINT_USERNAME, - HintConstants.AUTOFILL_HINT_NEW_USERNAME, - ) - - private val HINTS_NEW_PASSWORD = listOf( - HintConstants.AUTOFILL_HINT_NEW_PASSWORD, - ) - - private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf( - HintConstants.AUTOFILL_HINT_PASSWORD, - ) - - private val HINTS_OTP = listOf( - HintConstants.AUTOFILL_HINT_SMS_OTP, - ) - - @Suppress("DEPRECATION") - private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf( - HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, - HintConstants.AUTOFILL_HINT_NAME, - HintConstants.AUTOFILL_HINT_PERSON_NAME, - HintConstants.AUTOFILL_HINT_PHONE, - HintConstants.AUTOFILL_HINT_PHONE_NUMBER, - ) - - 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_OTP = listOf( - "tel", - "text", - ) - private val HTML_INPUT_FIELD_TYPES_FILLABLE = - (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList() - - @RequiresApi(Build.VERSION_CODES.O) - private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE - - 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", - "postal", // Prevent postal code fields from being mistaken for OTP fields - - ) - private val PASSWORD_HEURISTIC_TERMS = listOf( - "pass", - "pswd", - "pwd", - ) - private val USERNAME_HEURISTIC_TERMS = listOf( - "alias", - "e-mail", - "email", - "login", - "user", - ) - private val OTP_HEURISTIC_TERMS = listOf( - "einmal", - "otp", - "challenge", - "verification", - ) - private val OTP_WEAK_HEURISTIC_TERMS = listOf( - "code", - ) - } - - private val List<String>.anyMatchesFieldInfo - get() = any { - fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) - } - - 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 htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull() - 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() - val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty() - private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty() - private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty() - private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty() - - // W3C autocomplete hint detection for HTML fields - private val htmlAutocomplete = htmlAttributes["autocomplete"] - - // Ignored for now, see excludedByHints - private val excludedByAutocompleteHint = htmlAutocomplete == "off" - private val hasAutocompleteHintUsername = htmlAutocomplete == "username" - val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password" - private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password" - private val hasAutocompleteHintPassword = - hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword - private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code" - - // Results of hint-based field type detection - val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername - val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword - val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword - val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp - - // 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 - - // Only offer to fill into custom views if they explicitly opted into Autofill. - val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints - - // Exclude fields based on hint, resource ID or HTML name. - // 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.anyMatchesFieldInfo - private val notExcluded = relevantField && !hasExcludedTerm - - // Password field heuristics (based only on the current field) - private val isPossiblePasswordField = - notExcluded && (isAndroidPasswordField || isHtmlPasswordField) - private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword - private val isLikelyPasswordField = isPossiblePasswordField && - (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo) - val passwordCertainty = - if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible - - // OTP field heuristics (based only on the current field) - private val isPossibleOtpField = notExcluded && !isPossiblePasswordField - private val isCertainOtpField = isPossibleOtpField && hasHintOtp - private val isLikelyOtpField = isPossibleOtpField && ( - isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo || - ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo)) - val otpCertainty = - if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible - - // Username field heuristics (based only on the current field) - private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField - private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername - private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo)) - 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 }.minOrNull() ?: 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 }.maxOrNull() ?: 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, $autofillHints" - return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty" - } - - 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() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt index fd2997d8..ecec6747 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt @@ -4,6 +4,7 @@ */ package com.zeapo.pwdstore.autofill.oreo +import android.content.Context import android.os.Build import android.os.CancellationSignal import android.service.autofill.AutofillService @@ -15,10 +16,22 @@ import android.service.autofill.SaveRequest import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.FixedSaveCallback +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList +import com.github.androidpasswordstore.autofillparser.passwordValue +import com.github.androidpasswordstore.autofillparser.recoverNodes +import com.github.androidpasswordstore.autofillparser.usernameValue import com.zeapo.pwdstore.BuildConfig import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.getString import com.zeapo.pwdstore.utils.hasFlag +import com.zeapo.pwdstore.utils.sharedPrefs @RequiresApi(Build.VERSION_CODES.O) class OreoAutofillService : AutofillService() { @@ -66,13 +79,14 @@ class OreoAutofillService : AutofillService() { } val formToFill = FillableForm.parseAssistStructure( this, structure, - isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST + isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, + getCustomSuffixes(), ) ?: run { d { "Form cannot be filled" } callback.onSuccess(null) return } - formToFill.fillCredentials(this, callback) + AutofillResponseBuilder(formToFill).fillCredentials(this, callback) } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { @@ -113,3 +127,12 @@ class OreoAutofillService : AutofillService() { ) } } + +fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + +fun Context.getCustomSuffixes(): Sequence<String> { + return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + ?.splitToSequence('\n') + ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } + ?: emptySequence() +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt deleted file mode 100644 index 8107248e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/PublicSuffixListCache.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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.util.Patterns -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -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 { - // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne. - // We do not check whether the domain actually exists (actually, not even whether its TLD - // exists). As long as we restrict ourselves to syntactically valid domain names, - // getPublicSuffixPlusOne will return non-colliding results. - if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain) - .matches() - ) { - domain - } else { - getCanonicalSuffix(context, domain) - } -} - -/** - * Returns: - * - [domain], if [domain] equals [suffix]; - * - null, if [domain] does not have [suffix] as a domain suffix or only with an empty prefix; - * - the direct subdomain of [suffix] of which [domain] is a subdomain. - */ -fun getSuffixPlusUpToOne(domain: String, suffix: String): String? { - if (domain == suffix) - return domain - val prefix = domain.removeSuffix(".$suffix") - if (prefix == domain || prefix.isEmpty()) - return null - val lastPrefixPart = prefix.takeLastWhile { it != '.' } - return "$lastPrefixPart.$suffix" -} - -fun getCustomSuffixes(context: Context): Sequence<String> { - return context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) - ?.splitToSequence('\n') - ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } - ?: emptySequence() -} - -suspend fun getCanonicalSuffix(context: Context, domain: String): String { - val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context) - val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() - ?: return domain - var longestSuffix = publicSuffixPlusOne - for (customSuffix in getCustomSuffixes(context)) { - val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue - // A shorter suffix is automatically a substring. - if (suffixPlusUpToOne.length > longestSuffix.length) - longestSuffix = suffixPlusUpToOne - } - return longestSuffix -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt index 2c63c5c3..9bab9e6f 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt @@ -22,11 +22,11 @@ import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.Credentials +import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.autofill.oreo.FillableForm import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER import java.io.ByteArrayOutputStream @@ -109,7 +109,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { setResult(RESULT_CANCELED) } else { val fillInDataset = - FillableForm.makeFillInDataset( + AutofillResponseBuilder.makeFillInDataset( this@AutofillDecryptActivity, credentials, clientState, @@ -185,7 +185,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { @Suppress("BlockingMethodInNonBlockingContext") PasswordEntry(decryptedOutput) } - Credentials.fromStoreEntry(this, file, entry, directoryStructure) + AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) }.getOrElse { e -> e(e) { "Failed to parse password entry" } return null diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt index 7e29b061..95e49fdd 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -24,6 +24,7 @@ import androidx.core.widget.addTextChangedListener import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin import com.zeapo.pwdstore.FilterMode import com.zeapo.pwdstore.ListMode import com.zeapo.pwdstore.R @@ -33,7 +34,6 @@ import com.zeapo.pwdstore.SearchableRepositoryViewModel import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.autofill.oreo.FormOrigin import com.zeapo.pwdstore.databinding.ActivityOreoAutofillFilterBinding import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.viewBinding diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt index bcb27e65..44ed3446 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt @@ -16,13 +16,13 @@ import android.text.format.DateUtils import android.view.View import androidx.appcompat.app.AppCompatActivity import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching 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 com.zeapo.pwdstore.databinding.ActivityOreoAutofillPublisherChangedBinding import com.zeapo.pwdstore.utils.viewBinding diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt index 0052ff65..7f83d483 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt @@ -16,12 +16,12 @@ import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.autofill.oreo.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FormOrigin import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -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.autofill.oreo.AutofillResponseBuilder import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.utils.PasswordRepository import java.io.File @@ -126,7 +126,7 @@ class AutofillSaveActivity : AppCompatActivity() { return@registerForActivityResult } val credentials = Credentials(username, password, null) - val fillInDataset = FillableForm.makeFillInDataset( + val fillInDataset = AutofillResponseBuilder.makeFillInDataset( this, credentials, clientState, diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt deleted file mode 100644 index 0fb59002..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 - */ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package mozilla.components.lib.publicsuffixlist - -import android.content.Context -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async - -/** - * API for reading and accessing the public suffix list. - * - * > A "public suffix" is one under which Internet users can (or historically could) directly register names. Some - * > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known - * > public suffixes. - * - * Note that this implementation applies the rules of the public suffix list only and does not validate domains. - * - * https://publicsuffix.org/ - * https://github.com/publicsuffix/list - */ -class PublicSuffixList( - context: Context, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - private val scope: CoroutineScope = CoroutineScope(dispatcher) -) { - - private val data: PublicSuffixListData by lazy { PublicSuffixListLoader.load(context) } - - /** - * Prefetch the public suffix list from disk so that it is available in memory. - */ - fun prefetch(): Deferred<Unit> = scope.async { - data.run { Unit } - } - - /** - * Returns true if the given [domain] is a public suffix; false otherwise. - * - * E.g.: - * ``` - * co.uk -> true - * com -> true - * mozilla.org -> false - * org -> true - * ``` - * - * Note that this method ignores the default "prevailing rule" described in the formal public suffix list algorithm: - * If no rule matches then the passed [domain] is assumed to *not* be a public suffix. - * - * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values - * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. - */ - fun isPublicSuffix(domain: String): Deferred<Boolean> = scope.async { - when (data.getPublicSuffixOffset(domain)) { - is PublicSuffixOffset.PublicSuffix -> true - else -> false - } - } - - /** - * Returns the public suffix and one more level; known as the registrable domain. Returns `null` if - * [domain] is a public suffix itself. - * - * E.g.: - * ``` - * wwww.mozilla.org -> mozilla.org - * www.bcc.co.uk -> bbc.co.uk - * a.b.ide.kyoto.jp -> b.ide.kyoto.jp - * ``` - * - * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values - * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. - */ - fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = scope.async { - when (val offset = data.getPublicSuffixOffset(domain)) { - is PublicSuffixOffset.Offset -> domain - .split('.') - .drop(offset.value) - .joinToString(separator = ".") - else -> null - } - } - - /** - * Returns the public suffix of the given [domain]; known as the effective top-level domain (eTLD). Returns `null` - * if the [domain] is a public suffix itself. - * - * E.g.: - * ``` - * wwww.mozilla.org -> org - * www.bcc.co.uk -> co.uk - * a.b.ide.kyoto.jp -> ide.kyoto.jp - * ``` - * - * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values - * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. - */ - fun getPublicSuffix(domain: String) = scope.async { - when (val offset = data.getPublicSuffixOffset(domain)) { - is PublicSuffixOffset.Offset -> domain - .split('.') - .drop(offset.value + 1) - .joinToString(separator = ".") - else -> null - } - } - - /** - * Strips the public suffix from the given [domain]. Returns the original domain if no public suffix could be - * stripped. - * - * E.g.: - * ``` - * wwww.mozilla.org -> www.mozilla - * www.bcc.co.uk -> www.bbc - * a.b.ide.kyoto.jp -> a.b - * ``` - * - * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values - * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result. - */ - fun stripPublicSuffix(domain: String) = scope.async { - when (val offset = data.getPublicSuffixOffset(domain)) { - is PublicSuffixOffset.Offset -> domain - .split('.') - .joinToString(separator = ".", limit = offset.value + 1, truncated = "") - .dropLast(1) - else -> domain - } - } -} diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt deleted file mode 100644 index 778e9fee..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 - */ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package mozilla.components.lib.publicsuffixlist - -import mozilla.components.lib.publicsuffixlist.ext.binarySearch -import java.net.IDN - -/** - * Class wrapping the public suffix list data and offering methods for accessing rules in it. - */ -internal class PublicSuffixListData( - private val rules: ByteArray, - private val exceptions: ByteArray -) { - - private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? { - return rules.binarySearch(labels, labelIndex) - } - - private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? { - return exceptions.binarySearch(labels, labelIndex) - } - - @Suppress("ReturnCount") - fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? { - if (domain.isEmpty()) { - return null - } - - val domainLabels = IDN.toUnicode(domain).split('.') - if (domainLabels.find { it.isEmpty() } != null) { - // At least one of the labels is empty: Bail out. - return null - } - - val rule = findMatchingRule(domainLabels) - - if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) { - // The domain is a public suffix. - return if (rule == PublicSuffixListData.PREVAILING_RULE) { - PublicSuffixOffset.PrevailingRule - } else { - PublicSuffixOffset.PublicSuffix - } - } - - return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) { - // Exception rules hold the effective TLD plus one. - PublicSuffixOffset.Offset(domainLabels.size - rule.size) - } else { - // Otherwise the rule is for a public suffix, so we must take one more label. - PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1)) - } - } - - /** - * Find a matching rule for the given domain labels. - * - * This algorithm is based on OkHttp's PublicSuffixDatabase class: - * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java - */ - private fun findMatchingRule(domainLabels: List<String>): List<String> { - // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com]. - val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) } - - val exactMatch = findExactMatch(domainLabelsBytes) - val wildcardMatch = findWildcardMatch(domainLabelsBytes) - val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch) - - if (exceptionMatch != null) { - return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.') - } - - if (exactMatch == null && wildcardMatch == null) { - return PublicSuffixListData.PREVAILING_RULE - } - - val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE - val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE - - return if (exactRuleLabels.size > wildcardRuleLabels.size) { - exactRuleLabels - } else { - wildcardRuleLabels - } - } - - /** - * Returns an exact match or null. - */ - private fun findExactMatch(labels: List<ByteArray>): String? { - // Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com - // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins. - - for (i in 0 until labels.size) { - val rule = binarySearchRules(labels, i) - - if (rule != null) { - return rule - } - } - - return null - } - - /** - * Returns a wildcard match or null. - */ - private fun findWildcardMatch(labels: List<ByteArray>): String? { - // In theory, wildcard rules are not restricted to having the wildcard in the leftmost position. - // In practice, wildcards are always in the leftmost position. For now, this implementation - // cheats and does not attempt every possible permutation. Instead, it only considers wildcards - // in the leftmost position. We assert this fact when we generate the public suffix file. If - // this assertion ever fails we'll need to refactor this implementation. - if (labels.size > 1) { - val labelsWithWildcard = labels.toMutableList() - for (labelIndex in 0 until labelsWithWildcard.size) { - labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL - val rule = binarySearchRules(labelsWithWildcard, labelIndex) - if (rule != null) { - return rule - } - } - } - - return null - } - - private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? { - // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard. - if (wildcardMatch == null) { - return null - } - - for (labelIndex in 0 until labels.size) { - val rule = binarySearchExceptions(labels, labelIndex) - if (rule != null) { - return rule - } - } - - return null - } - - companion object { - - val WILDCARD_LABEL = byteArrayOf('*'.toByte()) - val PREVAILING_RULE = listOf("*") - val EMPTY_RULE = listOf<String>() - const val EXCEPTION_MARKER = '!' - } -} - -internal sealed class PublicSuffixOffset { - data class Offset(val value: Int) : PublicSuffixOffset() - object PublicSuffix : PublicSuffixOffset() - object PrevailingRule : PublicSuffixOffset() -} diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt deleted file mode 100644 index 65caeae5..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 - */ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package mozilla.components.lib.publicsuffixlist - -import android.content.Context -import java.io.BufferedInputStream -import java.io.IOException - -private const val PUBLIC_SUFFIX_LIST_FILE = "publicsuffixes" - -internal object PublicSuffixListLoader { - - fun load(context: Context): PublicSuffixListData = context.assets.open( - PUBLIC_SUFFIX_LIST_FILE - ).buffered().use { stream -> - val publicSuffixSize = stream.readInt() - val publicSuffixBytes = stream.readFully(publicSuffixSize) - - val exceptionSize = stream.readInt() - val exceptionBytes = stream.readFully(exceptionSize) - - PublicSuffixListData(publicSuffixBytes, exceptionBytes) - } -} - -@Suppress("MagicNumber") -private fun BufferedInputStream.readInt(): Int { - return (read() and 0xff shl 24 - or (read() and 0xff shl 16) - or (read() and 0xff shl 8) - or (read() and 0xff)) -} - -private fun BufferedInputStream.readFully(size: Int): ByteArray { - val bytes = ByteArray(size) - - var offset = 0 - while (offset < size) { - val read = read(bytes, offset, size - offset) - if (read == -1) { - throw IOException("Unexpected end of stream") - } - offset += read - } - - return bytes -} diff --git a/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt deleted file mode 100644 index 43fb7ab1..00000000 --- a/app/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 - */ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package mozilla.components.lib.publicsuffixlist.ext - -import kotlin.experimental.and - -private const val BITMASK = 0xff.toByte() - -/** - * Performs a binary search for the provided [labels] on the [ByteArray]'s data. - * - * This algorithm is based on OkHttp's PublicSuffixDatabase class: - * https://github.com/square/okhttp/blob/1977136/okhttp/src/main/kotlin/okhttp3/internal/publicsuffix/PublicSuffixDatabase.kt - */ -@Suppress("ComplexMethod", "NestedBlockDepth") -internal fun ByteArray.binarySearch(labels: List<ByteArray>, labelIndex: Int): String? { - var low = 0 - var high = size - var match: String? = null - - while (low < high) { - val mid = (low + high) / 2 - val start = findStartOfLineFromIndex(mid) - val end = findEndOfLineFromIndex(start) - - val publicSuffixLength = start + end - start - - var compareResult: Int - var currentLabelIndex = labelIndex - var currentLabelByteIndex = 0 - var publicSuffixByteIndex = 0 - - var expectDot = false - while (true) { - val byte0 = if (expectDot) { - expectDot = false - '.'.toByte() - } else { - labels[currentLabelIndex][currentLabelByteIndex] and BITMASK - } - - val byte1 = this[start + publicSuffixByteIndex] and BITMASK - - // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the - // unsigned bytes. - @Suppress("EXPERIMENTAL_API_USAGE") - compareResult = (byte0.toUByte() - byte1.toUByte()).toInt() - if (compareResult != 0) { - break - } - - publicSuffixByteIndex++ - currentLabelByteIndex++ - - if (publicSuffixByteIndex == publicSuffixLength) { - break - } - - if (labels[currentLabelIndex].size == currentLabelByteIndex) { - // We've exhausted our current label. Either there are more labels to compare, in which - // case we expect a dot as the next character. Otherwise, we've checked all our labels. - if (currentLabelIndex == labels.size - 1) { - break - } else { - currentLabelIndex++ - currentLabelByteIndex = -1 - expectDot = true - } - } - } - - if (compareResult < 0) { - high = start - 1 - } else if (compareResult > 0) { - low = start + end + 1 - } else { - // We found a match, but are the lengths equal? - val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex - var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex - for (i in currentLabelIndex + 1 until labels.size) { - labelBytesLeft += labels[i].size - } - - if (labelBytesLeft < publicSuffixBytesLeft) { - high = start - 1 - } else if (labelBytesLeft > publicSuffixBytesLeft) { - low = start + end + 1 - } else { - // Found a match. - match = String(this, start, publicSuffixLength, Charsets.UTF_8) - break - } - } - } - - return match -} - -/** - * Search for a '\n' that marks the start of a value. Don't go back past the start of the array. - */ -private fun ByteArray.findStartOfLineFromIndex(start: Int): Int { - var index = start - while (index > -1 && this[index] != '\n'.toByte()) { - index-- - } - index++ - return index -} - -/** - * Search for a '\n' that marks the end of a value. - */ -private fun ByteArray.findEndOfLineFromIndex(start: Int): Int { - var end = 1 - while (this[start + end] != '\n'.toByte()) { - end++ - } - return end -} |