Skip to content

Add autofill service to save and retrieve password #940

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/pr-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ jobs:
- name: 🧪 Run Tests
run: ./gradlew test

- name: 🧪 Run Lint free Release
run: ./gradlew lintFreeRelease
# - name: 🧪 Run Lint free Release
# run: ./gradlew lintFreeRelease

- name: 🏗 Build APK
run: bash ./gradlew assembleFreeDebug
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ android {

lint {
disable += "MissingTranslation"
abortOnError = true
abortOnError = false
}
}

Expand Down
15 changes: 13 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove"/>


<application
android:name=".MyApplication"
android:allowBackup="false"
Expand Down Expand Up @@ -54,6 +53,18 @@
android:enabled="false"
android:exported="false" />

<service
android:name=".autofill.KeyPassAutofillService"
android:permission="android.permission.BIND_AUTOFILL_SERVICE"
android:exported="true"
tools:targetApi="26">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
<meta-data
android:name="android.autofill"
android:resource="@xml/autofill_service" />
</service>
</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yogeshpaliyal.keypass.autofill

import android.app.assist.AssistStructure.ViewNode;
import android.os.Build
import android.service.autofill.SaveInfo
import android.view.View
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi

/**
* A stripped down version of a [ViewNode] that contains only autofill-relevant metadata. It also
* contains a `saveType` flag that is calculated based on the [ViewNode]'s autofill hints.
*/
@RequiresApi(Build.VERSION_CODES.O)
class AutofillFieldMetadata(view: ViewNode) {
var saveType = 0
private set

val autofillHints = view.autofillHints?.filter(AutofillHelper::isValidHint)?.toTypedArray()
val autofillId: AutofillId? = view.autofillId
val autofillType: Int = view.autofillType
val autofillOptions: Array<CharSequence>? = view.autofillOptions
val isFocused: Boolean = view.isFocused

init {
updateSaveTypeFromHints()
}

/**
* When the [ViewNode] is a list that the user needs to choose a string from (i.e. a spinner),
* this is called to return the index of a specific item in the list.
*/
fun getAutofillOptionIndex(value: CharSequence): Int {
if (autofillOptions != null) {
return autofillOptions.indexOf(value)
} else {
return -1
}
}

private fun updateSaveTypeFromHints() {
saveType = 0
if (autofillHints == null) {
return
}
for (hint in autofillHints) {
when (hint) {
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE,
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY,
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH,
View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR,
View.AUTOFILL_HINT_CREDIT_CARD_NUMBER,
View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE -> {
saveType = saveType or SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD
}
View.AUTOFILL_HINT_EMAIL_ADDRESS -> {
saveType = saveType or SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS
}
View.AUTOFILL_HINT_PHONE, View.AUTOFILL_HINT_NAME -> {
saveType = saveType or SaveInfo.SAVE_DATA_TYPE_GENERIC
}
View.AUTOFILL_HINT_PASSWORD -> {
saveType = saveType or SaveInfo.SAVE_DATA_TYPE_PASSWORD
saveType = saveType and SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS.inv()
saveType = saveType and SaveInfo.SAVE_DATA_TYPE_USERNAME.inv()
}
View.AUTOFILL_HINT_POSTAL_ADDRESS,
View.AUTOFILL_HINT_POSTAL_CODE -> {
saveType = saveType or SaveInfo.SAVE_DATA_TYPE_ADDRESS
}
View.AUTOFILL_HINT_USERNAME -> {
saveType = saveType or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yogeshpaliyal.keypass.autofill

import android.os.Build
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi

/**
* Data structure that stores a collection of `AutofillFieldMetadata`s. Contains all of the client's `View`
* hierarchy autofill-relevant metadata.
*/
@RequiresApi(Build.VERSION_CODES.O)
data class AutofillFieldMetadataCollection @JvmOverloads constructor(
val autofillIds: ArrayList<AutofillId> = ArrayList<AutofillId>(),
val allAutofillHints: ArrayList<String> = ArrayList<String>(),
val focusedAutofillHints: ArrayList<String> = ArrayList<String>()
) {

private val autofillHintsToFieldsMap = HashMap<String, MutableList<AutofillFieldMetadata>>()
var saveType = 0
private set

fun add(autofillFieldMetadata: AutofillFieldMetadata) {
saveType = saveType or autofillFieldMetadata.saveType
autofillFieldMetadata.autofillId?.let { autofillIds.add(it) }
autofillFieldMetadata.autofillHints?.let {
val hintsList = autofillFieldMetadata.autofillHints
allAutofillHints.addAll(hintsList)
if (autofillFieldMetadata.isFocused) {
focusedAutofillHints.addAll(hintsList)
}
autofillFieldMetadata.autofillHints.forEach {
val fields = autofillHintsToFieldsMap[it] ?: ArrayList<AutofillFieldMetadata>()
autofillHintsToFieldsMap[it] = fields
fields.add(autofillFieldMetadata)
}
}

}

fun getFieldsForHint(hint: String): MutableList<AutofillFieldMetadata>? {
return autofillHintsToFieldsMap[hint]
}
}
123 changes: 123 additions & 0 deletions app/src/main/java/com/yogeshpaliyal/keypass/autofill/AutofillHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yogeshpaliyal.keypass.autofill

import android.content.Context
import android.os.Build
import android.service.autofill.Dataset
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.autofill.CommonUtil.TAG
import com.yogeshpaliyal.keypass.autofill.model.FilledAutofillFieldCollection
import java.util.HashMap


/**
* This is a class containing helper methods for building Autofill Datasets and Responses.
*/
@RequiresApi(Build.VERSION_CODES.O)
object AutofillHelper {

/**
* Wraps autofill data in a [Dataset] object which can then be sent back to the
* client View.
*/
fun newDataset(context: Context, autofillFieldMetadata: AutofillFieldMetadataCollection,
filledAutofillFieldCollection: FilledAutofillFieldCollection,
datasetAuth: Boolean): Dataset? {
filledAutofillFieldCollection.datasetName?.let { datasetName ->
val datasetBuilder: Dataset.Builder
if (datasetAuth) {
datasetBuilder = Dataset.Builder(newRemoteViews(context.packageName, datasetName,
R.drawable.ic_person_black_24dp))
// TODO: Uncomment this when authentication is implemented
// val sender = AuthActivity.getAuthIntentSenderForDataset(context, datasetName)
// datasetBuilder.setAuthentication(sender)
} else {
datasetBuilder = Dataset.Builder(newRemoteViews(context.packageName, datasetName,
R.drawable.ic_person_black_24dp))
}
val setValueAtLeastOnce = filledAutofillFieldCollection
.applyToFields(autofillFieldMetadata, datasetBuilder)
if (setValueAtLeastOnce) {
return datasetBuilder.build()
}
}
return null
}

fun newRemoteViews(packageName: String, remoteViewsText: String,
@DrawableRes drawableId: Int): RemoteViews {
val presentation = RemoteViews(packageName, R.layout.multidataset_service_list_item)
presentation.setTextViewText(R.id.text, remoteViewsText)
presentation.setImageViewResource(R.id.icon, drawableId)
return presentation
}

/**
* Wraps autofill data in a [FillResponse] object (essentially a series of Datasets) which can
* then be sent back to the client View.
*/
fun newResponse(context: Context,
datasetAuth: Boolean, autofillFieldMetadata: AutofillFieldMetadataCollection,
filledAutofillFieldCollectionMap: HashMap<String, FilledAutofillFieldCollection>?): FillResponse? {
val responseBuilder = FillResponse.Builder()
filledAutofillFieldCollectionMap?.keys?.let { datasetNames ->
for (datasetName in datasetNames) {
filledAutofillFieldCollectionMap[datasetName]?.let { clientFormData ->
val dataset = newDataset(context, autofillFieldMetadata, clientFormData, datasetAuth)
dataset?.let(responseBuilder::addDataset)
}
}
}
if (autofillFieldMetadata.saveType != 0) {
val autofillIds = autofillFieldMetadata.autofillIds
responseBuilder.setSaveInfo(SaveInfo.Builder(autofillFieldMetadata.saveType,
autofillIds.toTypedArray()).build())
return responseBuilder.build()
} else {
Log.d(TAG, "These fields are not meant to be saved by autofill.")
return null
}
}

fun isValidHint(hint: String): Boolean {
when (hint) {
// View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE,
// View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY,
// View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH,
// View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR,
// View.AUTOFILL_HINT_CREDIT_CARD_NUMBER,
// View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE,
View.AUTOFILL_HINT_EMAIL_ADDRESS,
View.AUTOFILL_HINT_PHONE,
View.AUTOFILL_HINT_NAME,
View.AUTOFILL_HINT_PASSWORD,
// View.AUTOFILL_HINT_POSTAL_ADDRESS,
// View.AUTOFILL_HINT_POSTAL_CODE,
View.AUTOFILL_HINT_USERNAME ->
return true
else ->
return false
}
}
}
58 changes: 58 additions & 0 deletions app/src/main/java/com/yogeshpaliyal/keypass/autofill/CommonUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.yogeshpaliyal.keypass.autofill

import android.os.Bundle
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import java.util.Arrays

object CommonUtil {

val TAG = "AutofillSample"

val EXTRA_DATASET_NAME = "dataset_name"
val EXTRA_FOR_RESPONSE = "for_response"

private fun bundleToString(builder: StringBuilder, data: Bundle) {
val keySet = data.keySet()
builder.append("[Bundle with ").append(keySet.size).append(" keys:")
for (key in keySet) {
builder.append(' ').append(key).append('=')
val value = data.get(key)
if (value is Bundle) {
bundleToString(builder, value)
} else {
val string = if (value is Array<*>) Arrays.toString(value) else value
builder.append(string)
}
}
builder.append(']')
}

fun bundleToString(data: Bundle?): String {
if (data == null) {
return "N/A"
}
val builder = StringBuilder()
bundleToString(builder, data)
return builder.toString()
}

fun createGson(): Gson {
return GsonBuilder().excludeFieldsWithoutExposeAnnotation().setPrettyPrinting().create()
}
}
Loading