302 lines
12 KiB
Java
302 lines
12 KiB
Java
package org.julianfamily.keepassgo;
|
|
|
|
import android.app.PendingIntent;
|
|
import android.app.assist.AssistStructure;
|
|
import android.content.Intent;
|
|
import android.os.CancellationSignal;
|
|
import android.service.autofill.AutofillService;
|
|
import android.service.autofill.Dataset;
|
|
import android.service.autofill.FillCallback;
|
|
import android.service.autofill.FillContext;
|
|
import android.service.autofill.FillRequest;
|
|
import android.service.autofill.FillResponse;
|
|
import android.service.autofill.SaveCallback;
|
|
import android.service.autofill.SaveRequest;
|
|
import android.text.InputType;
|
|
import android.util.Log;
|
|
import android.view.View;
|
|
import android.view.autofill.AutofillId;
|
|
import android.view.autofill.AutofillValue;
|
|
import android.widget.RemoteViews;
|
|
|
|
import java.util.List;
|
|
|
|
public final class KeePassGOAutofillService extends AutofillService {
|
|
private static final String TAG = "KeePassGOAutofill";
|
|
private static final String APP_SCHEME = "androidapp://";
|
|
|
|
@Override
|
|
public void onConnected() {
|
|
super.onConnected();
|
|
Log.i(TAG, "service connected");
|
|
}
|
|
|
|
@Override
|
|
public void onDisconnected() {
|
|
Log.i(TAG, "service disconnected");
|
|
super.onDisconnected();
|
|
}
|
|
|
|
@Override
|
|
public void onFillRequest(
|
|
FillRequest request,
|
|
CancellationSignal cancellationSignal,
|
|
FillCallback callback
|
|
) {
|
|
try {
|
|
Log.i(TAG, "fill request flags=" + request.getFlags());
|
|
List<FillContext> contexts = request.getFillContexts();
|
|
if (contexts.isEmpty()) {
|
|
Log.i(TAG, "fill request had no contexts");
|
|
callback.onSuccess(null);
|
|
return;
|
|
}
|
|
|
|
AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
|
|
ParsedFields fields = new ParsedFields();
|
|
ParsedTarget target = parseWindow(structure, fields);
|
|
Log.i(
|
|
TAG,
|
|
"parsed target=" + target.matchTarget
|
|
+ " package=" + target.packageName
|
|
+ " usernameId=" + fields.usernameId
|
|
+ " passwordId=" + fields.passwordId
|
|
);
|
|
if (fields.passwordId == null) {
|
|
Log.i(TAG, "no password field found");
|
|
callback.onSuccess(null);
|
|
return;
|
|
}
|
|
|
|
AutofillCacheStore.Entry entry = findBoundEntry(target.matchTarget);
|
|
if (entry == null) {
|
|
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.relevantCandidates(this, target.matchTarget);
|
|
if (candidates.isEmpty()) {
|
|
FillResponse chooser = chooserResponse(target, fields);
|
|
if (chooser == null) {
|
|
Log.i(TAG, "no autofill cache match");
|
|
callback.onSuccess(null);
|
|
return;
|
|
}
|
|
Log.i(TAG, "returning searchable chooser dataset for " + target.matchTarget);
|
|
callback.onSuccess(chooser);
|
|
return;
|
|
}
|
|
if (candidates.size() > 1) {
|
|
Log.i(TAG, "returning " + candidates.size() + " scoped autofill datasets for " + target.matchTarget);
|
|
callback.onSuccess(candidatesFillResponse(candidates, fields));
|
|
return;
|
|
}
|
|
entry = candidates.get(0);
|
|
if (entry == null) {
|
|
Log.i(TAG, "no autofill cache match");
|
|
callback.onSuccess(null);
|
|
return;
|
|
}
|
|
}
|
|
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
|
|
|
FillResponse response = directFillResponse(entry, fields);
|
|
Log.i(TAG, "returning dataset");
|
|
callback.onSuccess(response);
|
|
} catch (Exception err) {
|
|
Log.e(TAG, "fill request failed", err);
|
|
callback.onFailure(err.getMessage());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
|
|
Log.i(TAG, "save request");
|
|
callback.onSuccess();
|
|
}
|
|
|
|
private AutofillCacheStore.Entry findBoundEntry(String matchTarget) {
|
|
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
|
|
if (!entryID.isEmpty()) {
|
|
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
|
|
if (bound != null) {
|
|
return bound;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
|
return new FillResponse.Builder()
|
|
.addDataset(datasetForEntry(entry, fields))
|
|
.build();
|
|
}
|
|
|
|
private FillResponse candidatesFillResponse(List<AutofillCacheStore.Entry> entries, ParsedFields fields) {
|
|
FillResponse.Builder response = new FillResponse.Builder();
|
|
for (AutofillCacheStore.Entry entry : entries) {
|
|
response.addDataset(datasetForEntry(entry, fields));
|
|
}
|
|
return response.build();
|
|
}
|
|
|
|
private Dataset datasetForEntry(AutofillCacheStore.Entry entry, ParsedFields fields) {
|
|
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
|
String label = entry.title == null || entry.title.trim().isEmpty() ? "KeePassGO entry" : entry.title;
|
|
if (entry.username != null && !entry.username.trim().isEmpty()) {
|
|
label += " (" + entry.username + ")";
|
|
}
|
|
presentation.setTextViewText(android.R.id.text1, label);
|
|
|
|
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
|
dataset.setId(entry.id);
|
|
if (fields.usernameId != null) {
|
|
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
|
}
|
|
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
|
return dataset.build();
|
|
}
|
|
|
|
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
|
|
if (fields.passwordId == null) {
|
|
return null;
|
|
}
|
|
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
|
|
if (candidates.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
|
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
|
|
|
|
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
|
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
|
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
|
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
|
|
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
|
|
|
|
PendingIntent pendingIntent = PendingIntent.getActivity(
|
|
this,
|
|
target.matchTarget.hashCode(),
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
|
|
);
|
|
|
|
AutofillId[] ids;
|
|
if (fields.usernameId != null) {
|
|
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
|
|
} else {
|
|
ids = new AutofillId[]{fields.passwordId};
|
|
}
|
|
return new FillResponse.Builder()
|
|
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
|
|
.build();
|
|
}
|
|
|
|
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
|
|
String domain = "";
|
|
final int windowCount = structure.getWindowNodeCount();
|
|
for (int i = 0; i < windowCount; i++) {
|
|
AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
|
|
String next = parseNode(root, fields);
|
|
if (!next.isEmpty()) {
|
|
domain = next;
|
|
}
|
|
}
|
|
String packageName = "";
|
|
if (structure.getActivityComponent() != null) {
|
|
packageName = structure.getActivityComponent().getPackageName();
|
|
}
|
|
if (!domain.isEmpty()) {
|
|
return new ParsedTarget(domain, packageName);
|
|
}
|
|
if (packageName != null && !packageName.isEmpty()) {
|
|
return new ParsedTarget(APP_SCHEME + packageName, packageName);
|
|
}
|
|
return new ParsedTarget("", "");
|
|
}
|
|
|
|
private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
|
|
String domain = "";
|
|
if (node.getWebDomain() != null) {
|
|
domain = node.getWebDomain();
|
|
}
|
|
|
|
AutofillId id = node.getAutofillId();
|
|
if (id != null) {
|
|
if (fields.passwordId == null && isPasswordNode(node)) {
|
|
fields.passwordId = id;
|
|
} else if (fields.usernameId == null && isUsernameNode(node)) {
|
|
fields.usernameId = id;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < node.getChildCount(); i++) {
|
|
String childDomain = parseNode(node.getChildAt(i), fields);
|
|
if (!childDomain.isEmpty()) {
|
|
domain = childDomain;
|
|
}
|
|
}
|
|
return domain;
|
|
}
|
|
|
|
private static boolean isPasswordNode(AssistStructure.ViewNode node) {
|
|
int inputType = node.getInputType();
|
|
int variation = inputType & InputType.TYPE_MASK_VARIATION;
|
|
if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
|
|
|| variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
|
|| variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
|
|
return true;
|
|
}
|
|
return nodeMatches(node, "password");
|
|
}
|
|
|
|
private static boolean isUsernameNode(AssistStructure.ViewNode node) {
|
|
int autofillType = node.getAutofillType();
|
|
if (autofillType != View.AUTOFILL_TYPE_TEXT) {
|
|
return false;
|
|
}
|
|
if (isPasswordNode(node)) {
|
|
return false;
|
|
}
|
|
return nodeMatches(node, "username")
|
|
|| nodeMatches(node, "email")
|
|
|| nodeMatches(node, "login")
|
|
|| nodeMatches(node, "user");
|
|
}
|
|
|
|
private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
|
|
String lowerTerm = term.toLowerCase();
|
|
String[] hints = node.getAutofillHints();
|
|
if (hints != null) {
|
|
for (String hint : hints) {
|
|
if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
if (containsLower(node.getHint(), lowerTerm)
|
|
|| containsLower(node.getIdEntry(), lowerTerm)
|
|
|| containsLower(node.getText(), lowerTerm)
|
|
|| containsLower(node.getContentDescription(), lowerTerm)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static boolean containsLower(CharSequence value, String term) {
|
|
return value != null && value.toString().toLowerCase().contains(term);
|
|
}
|
|
|
|
private static final class ParsedFields {
|
|
AutofillId usernameId;
|
|
AutofillId passwordId;
|
|
}
|
|
|
|
private static final class ParsedTarget {
|
|
final String matchTarget;
|
|
final String packageName;
|
|
|
|
ParsedTarget(String matchTarget, String packageName) {
|
|
this.matchTarget = matchTarget;
|
|
this.packageName = packageName;
|
|
}
|
|
}
|
|
}
|