Skip to content
This repository has been archived by the owner on Dec 14, 2021. It is now read-only.

initial autofilling #372

Merged
merged 16 commits into from
Jan 16, 2019
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package mozilla.lockbox

import android.support.test.rule.ServiceTestRule
import android.support.test.runner.AndroidJUnit4
import io.reactivex.Observable
import io.reactivex.subjects.PublishSubject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.appservices.logins.ServerPassword
import mozilla.lockbox.flux.Dispatcher
import mozilla.lockbox.store.DataStore
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@ExperimentalCoroutinesApi
class LockboxAutofillServiceTest {
val twitterCredential = ServerPassword(
"kjlfdsjlkf",
"twitter.com",
"[email protected]",
"dawgzone"
)

class FakeDataStore : DataStore() {
override val list: Observable<List<ServerPassword>> = PublishSubject.create()
}

private val dispatcher = Dispatcher()
private val dataStore = FakeDataStore()

@get:Rule
val serviceRule = ServiceTestRule()

val subject = LockboxAutofillService(dataStore, dispatcher)

@Before
fun setUp() {
subject.onConnected()
}

@After
fun tearDown() {
subject.onDisconnected()
}
}
107 changes: 105 additions & 2 deletions app/src/main/java/mozilla/lockbox/LockboxAutofillService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,123 @@ import android.annotation.TargetApi
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.addTo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.appservices.logins.ServerPassword
import mozilla.lockbox.action.DataStoreAction
import mozilla.lockbox.flux.Dispatcher
import mozilla.lockbox.model.titleFromHostname
import mozilla.lockbox.store.DataStore
import mozilla.lockbox.support.ParsedStructure

@TargetApi(Build.VERSION_CODES.O)
@ExperimentalCoroutinesApi
class LockboxAutofillService(
val dataStore: DataStore = DataStore.shared
val dataStore: DataStore = DataStore.shared,
val dispatcher: Dispatcher = Dispatcher.shared
) : AutofillService() {

override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {}
private var compositeDisposable = CompositeDisposable()

override fun onDisconnected() {
compositeDisposable.clear()
}

override fun onConnected() {
// stupidly unlock every time :D
dispatcher.dispatch(DataStoreAction.Unlock)
}

override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {
val structure = request.fillContexts.last().structure
val parsedStructure = ParsedStructure(structure)
val requestingPackage = domainFromPackage(structure.activityComponent.packageName)

if (parsedStructure.passwordId == null && parsedStructure.usernameId == null) {
callback.onFailure("couldn't find a username or password field")
sashei marked this conversation as resolved.
Show resolved Hide resolved
return
}

if (requestingPackage == null) {
callback.onFailure("unexpected package name structure")
return
}

dataStore.list
.filter { !it.isEmpty() }
.take(1)
.subscribe { passwords ->
val possibleValues = passwords.filter {
it.hostname.contains(requestingPackage, true)
sashei marked this conversation as resolved.
Show resolved Hide resolved
}
val response = buildFillResponse(possibleValues, parsedStructure)
if (response == null) {
callback.onFailure("no logins found for this domain")
} else {
callback.onSuccess(response)
}
}
.addTo(compositeDisposable)
}

private fun domainFromPackage(packageName: String): String? {
// naively assume that the `y` from `x.y.z`-style package name is the domain
// untested as we will change this implementation with issue #375
val domainRegex = Regex("^\\w+\\.(\\w+)\\..+")
return domainRegex.find(packageName)?.groupValues?.get(1)
}

private fun buildFillResponse(
possibleValues: List<ServerPassword>,
parsedStructure: ParsedStructure
): FillResponse? {
if (possibleValues.isEmpty()) {
return null
}

val dataset = datasetForPossibleValues(possibleValues, parsedStructure)
// future parts of this method include adding any authentication steps

return FillResponse.Builder()
.addDataset(dataset)
.build()
}

private fun datasetForPossibleValues(
possibleValues: List<ServerPassword>,
parsedStructure: ParsedStructure
): Dataset {
val datasetBuilder = Dataset.Builder()

possibleValues.forEach { credential ->
val usernamePresentation = RemoteViews(packageName, android.R.layout.simple_list_item_2)
val passwordPresentation = RemoteViews(packageName, android.R.layout.simple_list_item_2)
usernamePresentation.setTextViewText(android.R.id.text1, titleFromHostname(credential.hostname))
usernamePresentation.setTextViewText(android.R.id.text2, credential.username)
passwordPresentation.setTextViewText(android.R.id.text1, titleFromHostname(credential.hostname))
passwordPresentation.setTextViewText(android.R.id.text2, getString(R.string.password_for, credential.username))

parsedStructure.usernameId?.let {
datasetBuilder.setValue(it, AutofillValue.forText(credential.username), usernamePresentation)
}

parsedStructure.passwordId?.let {
datasetBuilder.setValue(it, AutofillValue.forText(credential.password), passwordPresentation)
}
}

return datasetBuilder.build()
}

// to be implemented in issue #217
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {}
}
113 changes: 113 additions & 0 deletions app/src/main/java/mozilla/lockbox/support/ParsedStructure.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package mozilla.lockbox.support

import android.annotation.TargetApi
import android.app.assist.AssistStructure
import android.os.Build
import android.view.View
import android.view.autofill.AutofillId

@TargetApi(Build.VERSION_CODES.O)
class ParsedStructure(structure: AssistStructure) {
var usernameId: AutofillId? = null
var passwordId: AutofillId? = null

init {
usernameId = getUsernameId(structure)
passwordId = getPasswordId(structure)
}

private fun getUsernameId(structure: AssistStructure): AutofillId? {
// how do we localize the "email" and "username"?
return getAutofillIdForKeywords(structure, listOf(
View.AUTOFILL_HINT_USERNAME,
View.AUTOFILL_HINT_EMAIL_ADDRESS,
"email",
"username",
"user name"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆

))
}

private fun getPasswordId(structure: AssistStructure): AutofillId? {
// similar l10n question for password
return getAutofillIdForKeywords(structure, listOf(View.AUTOFILL_HINT_PASSWORD, "password"))
}

private fun getAutofillIdForKeywords(structure: AssistStructure, keywords: List<String>): AutofillId? {
val windowNodes = structure
.run { (0 until windowNodeCount).map { getWindowNodeAt(it) } }

var possibleAutofillIds = windowNodes
.map { searchBasicAutofillContent(it.rootViewNode, keywords) }

if (possibleAutofillIds.filterNotNull().isEmpty()) {
possibleAutofillIds += windowNodes
.map { checkForConsecutiveKeywordAndField(it.rootViewNode, keywords) }
}

return try {
possibleAutofillIds.first { it != null }
} catch (e: NoSuchElementException) {
null
}
}

private fun searchBasicAutofillContent(viewNode: AssistStructure.ViewNode?, keywords: List<String>): AutofillId? {
val node = viewNode ?: return null

if (containsKeywords(node, keywords) &&
node.autofillId != null &&
node.className == "android.widget.EditText") {
return node.autofillId
}

return try {
node.run { (0 until childCount).map { getChildAt(it) } }
.map { searchBasicAutofillContent(it, keywords) }
.first { it != null }
} catch (e: NoSuchElementException) {
null
}
}

private fun checkForConsecutiveKeywordAndField(node: AssistStructure.ViewNode, keywords: List<String>): AutofillId? {
val childNodes = node
.run { (0 until childCount).map { getChildAt(it) } }

// check for consecutive views with keywords followed by possible fill locations
for (i in 1..(childNodes.size - 1)) {
val prevNode = childNodes[i - 1]
val currentNode = childNodes[i]
currentNode.autofillId?.let {
if (containsKeywords(prevNode, keywords) &&
currentNode.className == "android.widget.EditText") {
return it
}
}
}

return try {
childNodes
.map { checkForConsecutiveKeywordAndField(it, keywords) }
.first { it != null }
} catch (e: NoSuchElementException) {
null
}
}

private fun containsKeywords(node: AssistStructure.ViewNode, keywords: List<String>): Boolean {
val autofillHints = node.autofillHints?.toList() ?: emptyList()
var hints = listOf(node.hint) + listOf(node.text) + autofillHints

hints = hints.filterNotNull()

keywords.forEach { keyword ->
hints.forEach { hint ->
if (hint.contains(keyword, true)) {
return true
}
}
}

return false
}
}
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,7 @@
<string name="no_network">No Network</string>
<!-- This is displayed in an alert message to provide information and instruction to connect to the internet to use the application, not expected to translate Firefox Lockbox -->
<string name="connect_to_internet">Please connect to the internet to use Firefox Lockbox</string>
<!-- This is the hint value for an autofill box that appears when suggesting a value for a password field. It is interpolated with the user's account name. -->
<string name="password_for">Password for %1$s</string>

</resources>
Loading