基于Autofill Framework实现简单自动填充服务

964 阅读8分钟

2022年6月2号更新:

今天在进行开发的时候,客户提出一个要求。当通过Autofill触发,然后新建账号的时候,当新建账号结束,直接把账号的数据写入界面,而不是目前的需要再次点击一次RemoteView,之后把新建数据写入界面。

如下面:

autofill_sample.png 如果想要实现上面一行这种效果,而不是下面打X这行还需要再次点击RemoteView的效果的话,只需要在以前的代码基础上做两个非常小的修正。

  1. 身份验证的时候,不要调用FillResponse的setAuthentication()方法,而是调用Dataset的setAuthentication()方法。
  2. 在验证OK的时候,通过AutofillManager.EXTRA_AUTHENTICATION_RESULT传递结果的时候,不要传递FillResponse,而是传递Dataset对象。

2022年4月29号更新:

在实现AutofillService的过程中,发现在Chrome浏览器中FillUI显示不出来,而且AutofillService的onFillRequest()方法都不会调用。

今天突然发现在登录过Google账号,并且等待一小段时间之后,Autofill的FillUI突然可以在Chrome浏览器中显示出来了。经过对比,怀疑是Google GMS的更新,从而修正了这个问题。


随着手机功能的越来越强大,手机上面的功能越来越多,跟我们的生活越来越紧密,导致我们手机上搭载的App越来越多。而每个App又基本都有个注册登录的功能,所以用户的账号和密码也越来越多。Android在8.0的时候推出了Autofill Framework这一自动填充框架。

本文主要是基于Autofill Framework这一框架,构建自己的自动填充服务。

Manifest的声明和权限

如果想要提供自动填充服务,则必须在Manifest文件中进行一个声明,即在Manifest文件中包含一个元素。

示例:

<service
    android:name=".MyAutofillService"
    android:label="My Autofill Service"
    android:permission="android.permission.BIND_AUTOFILL_SERVICE">
    <intent-filter>
        <action android:name="android.service.autofill.AutofillService" />
    </intent-filter>
    <meta-data
        android:name="android.autofill"
        android:resource="@xml/service_configuration" />
</service>

结合上面的示例,分析下在元素中,应当包含以下的属性和元素:

● android:name 属性:实现自动填充服务的AutofillService的子类。

● android:label 属性: 自动填充服务的名字,即在设置中自动填充功能那块显示的名字。

● android:permission 属性:声明BIND_AUTOFILL_SERVICE权限。

● 元素:强制性的,元素只能指定为android.service.autofill.AutofillService

● 元素:可包含也可不包含该元素。可以用来为服务提供其他配置参数。元素中包含android:name属性和android:resource两个属性。

◆ android:name 属性:固定,只能指定为android.autofill。

◆ android:resource 属性:指向某个xml资源。该xml资源提供了关于服务的更多详细信息。上个示例中的 service_configuration资源指定了一个允许用户配置服务的 Activity。下面的示例则显示了该 service_configuration XML 资源:

<autofill-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsActivity="com.example.android.SettingsActivity" />

填充客户端视图

当用户与其他应用交互时,自动填充服务会收到填充客户端视图的请求。如果自动填充服务拥有可满足该请求的用户数据,那么它将在响应中发送这些数据。Android 系统会显示一个包含可用数据的自动填充界面,如下图所示:

qemu-system-x86_64_OPKao1vTgB.png

从上面的图片可以非常明显的发现,当用户点击Password这个输入框的时候,自动填充服务弹出了包含三个密码的界面(FillUi)给用户选择,当用户点击了某一个数据,那么Password的输入框中会自动填充用户选择的数据。

自动填充框架定义了一个填充视图的工作流程,以便最大程度地减少 Android 系统绑定到自动填充服务的时间。从用户点击某个输入框到显示数据视图的工作流程如下。

①. 用户点击相关的输入框。

②. View调用AutofillManager.notifyViewEntered(android.viewView)方法。

③. 创建一个屏幕中所有View的View的结构。

④. 系统绑定设置的自动填充服务并且调用onConnected()。

⑤. 自动填充服务调用onFillRequest(android.service.autofill.FillRequest, android.os.CancellationSignal, android.service.autofill.FillCallback)方法来接收刚才创建的屏幕所有View的结构。

⑥. 服务通过FillCallback.onSuccess(FillResponse)方法来返回能满足请求的数据或者返回null

⑦. 系统解除自动填充服务的绑定,并调用onDisconnected()。

⑧. 系统显示一个自动填充的UI(FillUI)。

⑨. 用户点击这个FillUI。

⑩. 数据填充到用户点击的输入框中。

在上面的流程中,最重要的就是步骤⑤和⑥。在每个请求中,Android 系统都通过调用onFillRequest()方法向自动服务发送一个AssistStructure对象。自动填充服务可以使用之前存储的用户数据检查自己是否有能满足请求的数据。如果服务能满足请求,那么可以将数据打包到Dataset对象中。服务调用FillResponse对象的onSuccess()方法,并传递刚才设置的Dataset对象。如果没有能满足请求的数据,则通过onSuccess()方法传递null。如果请求发生了错误,则调用onFailure()方法。

下面是onFillRequest()方法的示例:

    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
            FillCallback callback) {
        Log.d(TAG, "onFillRequest()");
​
        // Find autofillable fields
        AssistStructure structure = getLatestAssistStructure(request);
        Map<String, AutofillId> fields = getAutofillableFields(structure);
        Log.d(TAG, "autofillable fields:" + fields);
​
        if (fields.isEmpty()) {
            toast("No autofill hints found");
            callback.onSuccess(null);
            return;
        }
​
        // Create the base response
        FillResponse.Builder response = new FillResponse.Builder();
​
        // 1.Add the dynamic datasets
        String packageName = getApplicationContext().getPackageName();
        for (int i = 1; i <= NUMBER_DATASETS; i++) {
            Dataset.Builder dataset = new Dataset.Builder();
            for (Entry<String, AutofillId> field : fields.entrySet()) {
                String hint = field.getKey();
                AutofillId id = field.getValue();
                String value = i + "-" + hint;
                // We're simple - our dataset values are hardcoded as "N-hint" (for example,
                // "1-username", "2-username") and they're displayed as such, except if they're a
                // password
                String displayValue = hint.contains("password") ? "password for #" + i : value;
                RemoteViews presentation = newDatasetPresentation(packageName, displayValue);
                dataset.setValue(id, AutofillValue.forText(value), presentation);
            }
            response.addDataset(dataset.build());
        }
​
        // 2.Add save info
        Collection<AutofillId> ids = fields.values();
        AutofillId[] requiredIds = new AutofillId[ids.size()];
        ids.toArray(requiredIds);
        response.setSaveInfo(
                // We're simple, so we're generic
                new SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_GENERIC, requiredIds).build());
​
        // 3.Profit!
        callback.onSuccess(response.build());
    }
​
    /**
     * Helper method to get the {@link AssistStructure} associated with the latest request
     * in an autofill context.
     */
    @NonNull
    static AssistStructure getLatestAssistStructure(@NonNull FillRequest request) {
        List<FillContext> fillContexts = request.getFillContexts();
        return fillContexts.get(fillContexts.size() - 1).getStructure();
    }
​
    /**
     * Parses the {@link AssistStructure} representing the activity being autofilled, and returns a
     * map of autofillable fields (represented by their autofill ids) mapped by the hint associate
     * with them.
     *
     * <p>An autofillable field is a {@link ViewNode} whose {@link #getHint(ViewNode)} metho
     */
    @NonNull
    private Map<String, AutofillId> getAutofillableFields(@NonNull AssistStructure structure) {
        Map<String, AutofillId> fields = new ArrayMap<>();
        int nodes = structure.getWindowNodeCount();
        for (int i = 0; i < nodes; i++) {
            ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
            addAutofillableFields(fields, node);
        }
        return fields;
    }
​
    /**
     * Adds any autofillable view from the {@link ViewNode} and its descendants to the map.
     */
    private void addAutofillableFields(@NonNull Map<String, AutofillId> fields,
            @NonNull ViewNode node) {
        String[] hints = node.getAutofillHints();
        if (hints != null) {
            // We're simple, we only care about the first hint
            String hint = hints[0].toLowerCase();
​
            if (hint != null) {
                AutofillId id = node.getAutofillId();
                if (!fields.containsKey(hint)) {
                    Log.v(TAG, "Setting hint '" + hint + "' on " + id);
                    fields.put(hint, id);
                } else {
                    Log.v(TAG, "Ignoring hint '" + hint + "' on " + id
                            + " because it was already set");
                }
            }
        }
        int childrenSize = node.getChildCount();
        for (int i = 0; i < childrenSize; i++) {
            addAutofillableFields(fields, node.getChildAt(i));
        }
    }

通过上面的示例代码,可以发现,在onFillRequest()方法中,首先通过FillRequest获取到AssistStructure对象。然后可以通过AssistStructure对象,来遍历结构,获取全部节点(ViewNode)。然后服务可以根据当前AssistStructure对象的packageName(AssistStructure.getActivityComponent().getPackageName())或者domain(AssistStructure.ViewNode.getWebDomain())等信息来跟保存的数据进行比对,如果数据符合请求的情况下,可以new RemoteViews用来显示FillUI,然后把数据(AutofillValue.forText(value))和RemoteView打包进Dataset。最后调用FillResponse对象的onSuccess()方法,并传递刚才设置的Dataset对象。这样用户就可以看到一个可以选择数据的视图。

注:上面的示例代码没有进行数据是否符合请求的判断,而是直接通过假数据的方式,来进行示例。同时虽然只显示一个FillUI,但是当用户点击这个View之后,所有配置了AutofillHint的EditText都将填上相关数据,毕竟过滤了整个ViewNode(for (Entry<String, AutofillId> field : fields.entrySet()))。

保存用户数据

当用户第一次填充数据之后,系统会提示他们将数据保存至当前的自动填充服务,View(SaveUi)的式样如下图:

qemu-system-x86_64_nGQxjH3AsT.png

在 Android 系统发送保存数据的请求之前,用户已经填充了相关数据。服务为了表明它想保存数据,服务应包含一个SaveInfo对象来响应先前的填充请求。SaveInfo对象中至少包含以下数据:

  • 要保存的用户数据类型。
  • 需要更改以触发保存请求的最小视图集。例如,登录表单通常要求用户更新 usernamepassword 视图以触发保存请求。

如上面示例代码:

        // 2.Add save info
        Collection<AutofillId> ids = fields.values();
        AutofillId[] requiredIds = new AutofillId[ids.size()];
        ids.toArray(requiredIds);
        response.setSaveInfo(
                // We're simple, so we're generic
                new SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_GENERIC, requiredIds).build());

从用户输入数据到保存数据到自动填充服务的工作流程如下:

①. 用户在客户端输入相关数据。

②. 自动填充服务触发onFillRequest()方法,并且服务在该方法中new一个SaveInfo对象,表明它想保存数据。

③. 客户端 Activity 结束后或当客户端应用调用 commit()。

④. 系统显示提醒是否保存数据到当前自动填充服务的View(SaveUi)。

⑤. 用户点击保存按钮。

⑥. 自动填充服务触发onSaveRequest(SaveRequest request, SaveCallback callback)方法。

⑦. 自动填充服务保存数据。

下面是onSaveRequest()方法的示例:

@Override
public void onSaveRequest(SaveRequest request, SaveCallback callback) {
    // Get the structure from the request
    List<FillContext> context = request.getFillContexts();
    AssistStructure structure = context.get(context.size() - 1).getStructure();
​
    // Traverse the structure looking for data to save
    traverseStructure(structure);
​
    // Persist the data, if there are no errors, call onSuccess()
    callback.onSuccess();
}

身份验证

身份验证是一个非常有必要的功能,因为如果只要用户点击了Autofill的FillUI就填写相关敏感数据的话,数据的安全性得不到一点保证。所以当用户点击了FillUI之后,首先进行相关身份验证,身份验证通过的话,再传递数据,从而提高安全性。

下面是身份验证的相关示例:

①在onFillRequest方法中,新建FillUI的时候,调用setAuthentication方法设置进行验证的相关Activity。

    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
            FillCallback callback) {
        ......
        Intent authIntent = new Intent(this, AuthActivity.class);  //AuthActivity:进行验证的Activity
        IntentSender intentSender = PendingIntent.getActivity(
                        this,
                        1001,
                        authIntent,
                        PendingIntent.FLAG_CANCEL_CURRENT
                ).getIntentSender();
​
        // Build a FillResponse object that requires authentication.
        FillResponse fillResponse = new FillResponse.Builder()
                .setAuthentication(autofillIds, intentSender, remoteViews)
                .build();
        ......
    }

②在AuthActivity中,当身份验证OK的情况下,调用如下的代码,返回带数据的FillResponse。这样FillUI会自动进行更新,并且点击的话,会把相关数据填写进EditText。

Intent intent = getIntent();
​
//不需要主动put,系统自动put。
//但是如果你的Service是面向Android 12的话,在setAuthentication的时候,
//传递的PendingIntent必须添加FLAG_MUTABLE,否则AssistStructure将为null。
AssistStructure structure = intent.getParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE); 
Map<String, AutofillId> fields = getAutofillableFields(structure);
// Create the base response
FillResponse.Builder response = new FillResponse.Builder();
        
// 1.Add the dynamic datasets
String packageName = getApplicationContext().getPackageName();
for (int i = 1; i <= NUMBER_DATASETS; i++) {
    Dataset.Builder dataset = new Dataset.Builder();
    for (Entry<String, AutofillId> field : fields.entrySet()) {
        String hint = field.getKey();
        AutofillId id = field.getValue();
        String value = i + "-" + hint;
        String displayValue = hint.contains("password") ? "password for #" + i : value;
        RemoteViews presentation = newDatasetPresentation(packageName, displayValue);
        dataset.setValue(id, AutofillValue.forText(value), presentation);
    }
    response.addDataset(dataset.build());
}
​
Intent replyIntent = new Intent();
​
// AutofillManager.EXTRA_AUTHENTICATION_RESULT和
//setResult(RESULT_OK, replyIntent)方法结合,系统会自动更新FillUI和相关数据
replyIntent.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse);
setResult(RESULT_OK, replyIntent);
​
finish();

以上就是一个非常简单的Autofill Framework的实现。