今天要來寫關於「自動填入」資訊的實作...好吧,老實說我也不知道要怎麼口述這個功能XD

在英文的話,他的關鍵字叫做Autofill。也就是你可能在用APP或者網頁時,Google會問你要不要儲存密碼~?的這個功能

Build autofill services | Android Developers

↑像是這樣的

 

網頁版的話大概是類似這個

截圖 2023-05-20 下午10.01.59

 

而這個功能呢...其實也是工作中獲得的靈感XD

在最近的工作中就有遇到項目方希望我們前端可以去控制這個儲存密碼提示出現的時間點,基本就是在輸入密碼之後啦...之類的

後來我去了解了一下,發現Android中有提供一個叫做AutofillService框架的東西,這個相當於訪間的「密碼管理工具」的製作原型

於是...我幾乎花了一個禮拜去研究==....

 

沒辦法,這個資料超級少,有做出完整功能的人並不多

我想正在看這篇文章的你大概很幸運...因為這功能真的太罕見了,連ChatGPT都說:

截圖 2023-05-20 下午10.07.45

 

(゚´ω`゚)゚

 

那來看一下這次的功能吧

 

然後是

Gitnub

 

那,開始吧!


 

1. 解釋功能與前置作業

 

首先關於這個Android的AutofillService框架的參考資料除了官方網站很詳細又太過詳細的文檔之外

->https://developer.android.com/guide/topics/text/autofill-services?hl=zh-tw

我個人還有另外去載也是官方提供的Github範例文件作為參考

->https://github.com/android/input-samples/tree/main/AutofillFramework

而這項功能Google那邊寫得複雜得要死,所以經過我個人極端的去蕪存菁之後,就寫出了今天的功能

 

而也是因為我是參考著Google提供的範例去完成的,因此如果仔細看我的Code就會發現其實有些命名跟寫法是跟他們接近的

這部分就請見諒囉:D

 

好,回歸正題

首先來看一下這次的專案架構

截圖 2023-05-20 下午10.25.32

 

因為這次的內容整理來說很複雜,所以我這裡詳細標注了所有必要檔案的工作

 

好,接下來來先導入必要的檔案

首先是xml的設定檔,這部分直接Copy就可以了(因為我也是Copy來的哈哈)

user_service.xml

<?xml version="1.0" encoding="utf-8"?><!--
 * Copyright (C) 2018 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.
-->

<!-- TODO(b/114236837): use its own Settings Activity -->
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsActivity="com.example.android.autofill.service.settings.SettingsActivity">

    <!-- sample app -->
    <compatibility-package
        android:name="com.example.android.autofill.app"
        android:maxLongVersionCode="10000000000"/>

    <!-- well-known browswers, alphabetical order -->
    <compatibility-package
        android:name="com.android.chrome"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.chrome.beta"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.chrome.dev"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.chrome.canary"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.microsoft.emmx"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.opera.browser"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.opera.browser.beta"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.opera.mini.native"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.opera.mini.native.beta"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.sec.android.app.sbrowser"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="com.sec.android.app.sbrowser.beta"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="org.chromium.chrome"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="org.mozilla.fennec_aurora"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="org.mozilla.firefox"
        android:maxLongVersionCode="10000000000"/>
    <compatibility-package
        android:name="org.mozilla.firefox_beta"
        android:maxLongVersionCode="10000000000"/>
</autofill-service>

 

...但老實說我也不太確定是那來幹嘛的,沒想很多就導入了,然後上傳後才發現有這東西XD

 

好啦,接著繼續要來創建Service

首先新增一個Class,然後使之繼承AutofillService,再依據紅字複寫幾個方法

截圖 2023-05-20 下午10.43.02

 

最後會變成這樣(空白的樣式)

public class MyAutofill extends AutofillService {
    @Override
    public void onFillRequest(@NonNull FillRequest fillRequest, @NonNull CancellationSignal cancellationSignal, @NonNull FillCallback fillCallback) {

    }

    @Override
    public void onSaveRequest(@NonNull SaveRequest saveRequest, @NonNull SaveCallback saveCallback) {

    }
}

 

再來,請加入複寫(Override),加入onConnected

截圖 2023-05-20 下午4.25.06

 

最後會長這樣

public class MyAutofill extends AutofillService {


    @Override
    public void onConnected() {
        super.onConnected();
    }

    @Override
    public void onFillRequest(@NonNull FillRequest fillRequest, @NonNull CancellationSignal cancellationSignal, @NonNull FillCallback fillCallback) {

    }

    @Override
    public void onSaveRequest(@NonNull SaveRequest saveRequest, @NonNull SaveCallback saveCallback) {

    }
}

 

 

而關於這些的功能我在後面都會講,現階段先照做即可

 

再來因為加入了Service的緣故,因此要去AndroidMinifest.xml做登錄。內容如下(粉底白字部分)

 

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AutofillExample"
        tools:targetApi="31">
        <activity
            android:name=".Activity.SuccessPage"
            android:exported="false">
            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>
        <activity
            android:name=".Activity.MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>
        <service
            android:name=".Service.MyAutofill"
            android:exported="true"
            android:icon="@drawable/ic_baseline_key_24"
            android:label="自定義的自動填入系統"
            android:permission="android.permission.BIND_AUTOFILL_SERVICE">
            <meta-data

                android:name="android.autofill"
                android:resource="@xml/user_service" />
            <intent-filter>
                <action android:name="android.service.autofill.AutofillService"/>
            </intent-filter>
        </service>
    </application>

</manifest>

 

好的,到此為止所有的事前工作都完成了

接下來我們先把整體的介面完成吧

 


 

2. 完成介面

 

這次的完成介面跟之前的完成介面稍微不太ㄧ樣,

具體來說,其實這次的功能就像是把介面跟Service完全分開,所以只要這個Service有被系統下載過,原則上就會被系統列入到名單中

OK,那就讓我們開始吧

 

首先從簡單的來,我們先來寫入成功頁面

首先是xml檔案

activity_success_page.xml

截圖 2023-05-20 下午11.18.46

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingBottom="10dp"
    android:paddingTop="10dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        style="@style/TextAppearance.AppCompat.Large"
        android:text="Success" />

    <TextView
        android:id="@+id/countdownText"
        style="@style/TextAppearance.AppCompat.Body1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="32dp" />
</LinearLayout>

 

再來是Java檔案

SuccessPage.java

public class SuccessPage extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_success_page);
        TextView countdownText = findViewById(R.id.countdownText);
        new CountDownTimer(5000, 1000) {
            @Override
            public void onTick(long millisUntilFinished) {
                int secondsRemaining = toIntExact(millisUntilFinished / 1000);
                countdownText.setText(getResources().getQuantityString
                        (R.plurals.welcome_page_countdown, secondsRemaining, secondsRemaining));
            }

            @Override
            public void onFinish() {
                if (!SuccessPage.this.isFinishing()) {
                    Intent intent = new Intent(SuccessPage.this,MainActivity.class);
                    startActivity(intent);
                    finish();
                }
            }
        }.start();
    }
}

 

 

再來是主頁面

activity_main.xml

截圖 2023-05-20 下午11.20.05

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/authLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingBottom="16dp"
    android:paddingLeft="16dp"
    android:paddingRight="16dp"
    android:paddingTop="16dp">

    <TextView
        android:id="@+id/standard_login_header"
        style="@style/TextAppearance.AppCompat.Large"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:gravity="center"
        android:text="Login"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintHorizontal_chainStyle="spread"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <TextView
        android:id="@+id/usernameLabel"
        style="@style/TextAppearance.AppCompat.Body1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:layout_marginEnd="6dp"
        android:labelFor="@+id/usernameField"
        android:text="Name"
        app:layout_constraintEnd_toStartOf="@+id/usernameField"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toStartOf="@+id/passwordLabel"
        app:layout_constraintTop_toBottomOf="@+id/standard_login_header" />

    <EditText
        android:id="@+id/usernameField"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:autofillHints="username"
        android:background="@color/white"
        android:focusable="false"
        android:inputType="text"
        android:text="Noah"
        app:layout_constraintBottom_toBottomOf="@+id/usernameLabel"
        app:layout_constraintStart_toStartOf="@+id/passwordField"
        app:layout_constraintTop_toTopOf="@+id/usernameLabel" />

    <TextView
        android:id="@+id/passwordLabel"
        style="@style/TextAppearance.AppCompat.Body1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="26dp"
        android:labelFor="@+id/passwordField"
        android:text="Password"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/usernameLabel" />

    <EditText
        android:id="@+id/passwordField"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="6dp"
        android:layout_marginEnd="6dp"
        android:layout_marginStart="12dp"
        android:autofillHints="password"
        android:inputType="text"
        app:layout_constraintBottom_toBottomOf="@+id/passwordLabel"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/passwordLabel"
        app:layout_constraintTop_toTopOf="@+id/passwordLabel" />

    <TextView
        android:id="@+id/clear"
        style="@style/Widget.AppCompat.Button.Borderless"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="6dp"
        android:layout_marginTop="6dp"
        android:text="Clear"
        android:textColor="@android:color/holo_blue_dark"
        app:layout_constraintEnd_toStartOf="@+id/login"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/passwordField" />

    <TextView
        android:id="@+id/login"
        style="@style/Widget.AppCompat.Button.Borderless"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="6dp"
        android:layout_marginStart="6dp"
        android:text="Login"
        android:textColor="@android:color/holo_blue_dark"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/clear"
        app:layout_constraintTop_toTopOf="@+id/clear" />


</androidx.constraintlayout.widget.ConstraintLayout>

 

雖然介面一般是沒什麼好注意的,不過在本篇要注意到紅底白字的部分

在標注起來的部分他的類別是autofillHints,這個意味著之後我們在做autofill功能時,會根據這個標籤去做判斷或者填入之意

而如果想要套用Google那邊的自動填入系統的話,跟我個人經驗也是透過投入這個標籤去完成...

 

說是這樣說,不過由於那個機制是個黑盒子,所以到底在什麼樣的條件會觸發其實我也沒有個準..(´,_ゝ`)

不過這次的Autofill功能是自己寫的話,這個標籤就會有參考價值了

這部分在稍後的程式碼中也會寫道~

 

接著繼續class檔案~

 

MainActivity.java

public class MainActivity extends AppCompatActivity {

    private EditText mUsernameEditText;
    private EditText mPasswordEditText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //啟用AutofillManager
        getSystemService(AutofillManager.class);
        //跳入設定自動填入供應商的設定畫面
        Intent intent = new Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE);
        intent.setData(Uri.parse("package:com.android.settings"));
        startActivityForResult(intent, 100);
        mUsernameEditText = findViewById(R.id.usernameField);
        mPasswordEditText = findViewById(R.id.passwordField);
        findViewById(R.id.login).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                login();
            }
        });
        findViewById(R.id.clear).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                AutofillManager afm = getSystemService(AutofillManager.class);
                if (afm != null) {
                    afm.cancel();
                }
                mUsernameEditText.setText("");
                mPasswordEditText.setText("");
            }
        });

    }

    private void login() {
        String username = mUsernameEditText.getText().toString();
        String password = mPasswordEditText.getText().toString();
        if (username.length() == 0 || password.length() == 0) return;
        Intent intent = new Intent(this, SuccessPage.class);
        startActivity(intent);
        finish();

    }
}

 

 

在Activity這部分也有要注意的地方

首先是橘底白字的部分,這裡是告訴System我們這隻程式即將使用Autofill功能

再來是粉底白字標籤處,這裡是讓使用者直接跳進設定->自動填入服務的頁面

截圖 2023-05-20 下午11.34.24

 

而在這個頁面,手機會自動顯示這隻手機內提供自動儲存服務的所有APP的名字,其中因為我們前面已經設置好了Service,所以在這邊就會自動出現囉!

 

再來是關於下拉式選單的樣子,基本上那部分是靠RemoteViews完成的,而RemoteViews的內容稍後在寫

但我們在這裡必須要寫介面

如下

user_suggestion_item.xml

截圖 2023-05-20 下午11.56.39

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:padding="6dp"
    >
    <ImageView
        android:id="@+id/icon"
        android:layout_gravity="center"
        android:layout_height="wrap_content"
        android:layout_marginEnd="4dp"
        android:layout_width="wrap_content"
        android:src="@drawable/ic_baseline_key_24" />
    <TextView
        android:id="@+id/user_suggestion_item"
        android:text="Word"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="16dp"
        android:textSize="16sp"
        android:textStyle="bold"/>


</LinearLayout>

 

喔對了,圖片的檔案在這裡,前面忘記放了

ic_baseline_key_24.xml

 

到這邊按下執行,應該是可以正常跑的

不過因為還沒有寫自動填入的關係,所以目前還是沒有功能的哦!

 


 

3. 重點來惹d(`・∀・)b

 

沒錯,重點來了(笑

 

基本上如果有成功弄好上面的步驟的話,此時APP是可以執行的

不過還沒有自動填入系統的輔助,因此目前是沒有想要的功能的

 

好的,接下來我們就把焦點轉到MyAutofill這隻檔案

截圖 2023-05-20 下午10.25.32

 

首先來看一下三個繼承

public class MyAutofill extends AutofillService {

    private final static String TAG = AutofillService.class.getSimpleName();

    @Override
    public void onConnected() {
        super.onConnected();
       //可以在這裡做初始設定

    }

    //當介面被觸發時回調這裡
    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
                              FillCallback callback) {

        
    }

 
    /**當在完成登入後的填出視窗,若使用者選擇儲存的話便會來到這個回調*/
    @Override
    public void onSaveRequest(SaveRequest request, SaveCallback callback) {
       
    }
    
}

 

大體上來說這個功能就是圍繞著這三個功能轉的

首先,onConnected基本上就是初始化時的一個回條,可以當作onCreate一樣的感覺

再來onFillRequest是當物件被點擊後就會觸發這個回調,而這裡也是整個功能的最大骨幹

最後的onSaveRequest回調

截圖 2023-05-21 上午12.06.22

則是這裡的畫面按下「儲存」後的回調

 

OK,知道了每個功能後,就下來就要開始寫了

程式碼的解釋我都有寫註解,所以我只貼段落,然後告訴你邏輯的跑法

不想看寫是的人可以直接跳到最後看完整程式喔!

.

.

.

 

首先感謝你沒有拉到最底XD

整體上來說,本程式要分成三個部分處理

1. 遍歷整個介面的元件,抓出有設定autofillHints的元件

2. 撰寫點擊元件後出現的填入提示

3. 如果使用者沒有使用建議的密碼,在他點選「儲存」的時候撈取回調

 

嗯嗯,基本上就這三個大方針

那麼就一個一個來吧,首先我們要抓出整個介面中有設置autofill的元件

如下

 

public class MyAutofill extends AutofillService {

    private final static String TAG = AutofillService.class.getSimpleName();

    @Override
    public void onConnected() {
        super.onConnected();
       //可以在這裡做初始設定

    }

    //當介面被觸發時回調這裡
    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
                              FillCallback callback) {

        // 取得介面上所有的元件,並以陣列輸出有設定"hint"的元件之id
        List<FillContext> fillContexts = request.getFillContexts();
        AssistStructure structure = fillContexts.get(fillContexts.size() - 1).getStructure();
        //過濾出有設定"hint"屬性的元件,並輸出成陣列
        ArrayMap<String, AutofillId> fields = getAutofillableFields(structure);
        Log.e(TAG, "在目前介面中有被設定的元件列表:\n" + fields);

    }

    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    @NonNull
    private ArrayMap<String, AutofillId> getAutofillableFields(@NonNull AssistStructure structure) {
        ArrayMap<String, AutofillId> fields = new ArrayMap<>();
        //取得該畫面中的節點數量
        int nodes = structure.getWindowNodeCount();
        for (int i = 0; i < nodes; i++) {
            AssistStructure.ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
            addAutofillableFields(fields, node);
        }
        return fields;
    }

    private void addAutofillableFields(@NonNull Map<String, AutofillId> fields,
                                       @NonNull AssistStructure.ViewNode node) {
        String hint = getHint(node);
        if (hint != null) {
            AutofillId id = node.getAutofillId();
            if (!fields.containsKey(hint)) {
                Log.e(TAG, "已找到有設定autofillHints的元件,hint '" + hint + "' 設定ID " + id);
                fields.put(hint, id);
            } else {
                //如果有重複設定的元件的話將會到這邊
                //比方說已經有檢測到某元件在前面被設定過的話,後面被偵測到的元件將會被無視掉
                Log.d(TAG, "忽視掉有重複設定autofillHints的元件,hint '" + hint + "' 設定ID " + id);
            }
        }
        //利用遞歸法持續遍歷整個介面
        int childrenSize = node.getChildCount();
        for (int i = 0; i < childrenSize; i++) {
            addAutofillableFields(fields, node.getChildAt(i));
        }
    }

    @Nullable
    protected String getHint(@NonNull AssistStructure.ViewNode node) {

        String viewHint = node.getHint();
        String hint = inferHint(node, viewHint);

        if (hint != null) {
            Log.e(TAG, "找到元件, hint(" + viewHint + "): " + hint);
            return hint;
        } else if (!TextUtils.isEmpty(viewHint)) {
            Log.d(TAG, "無提示元件: " + viewHint);
        }
        String resourceId = node.getIdEntry();
        hint = inferHint(node, resourceId);
        if (hint != null) {
            Log.e(TAG, "找到有提示的元件ID: (" + resourceId + "): " + hint);
            return hint;
        } else if (!TextUtils.isEmpty(resourceId)) {
            Log.d(TAG, "無使用提示的元件ID: " + resourceId);
        }

        //這邊是官方給的示範,意味著你可以僅過濾某個Class、某個字元或者只去過濾EditText之類的
        CharSequence text = node.getText();
        CharSequence className = node.getClassName();
        if (text != null && className != null && className.toString().contains("EditText")) {
            hint = inferHint(node, text.toString());
            if (hint != null) {
                Log.d(TAG, "Found hint using text(" + text + "): " + hint);
                return hint;
            }
        } else if (!TextUtils.isEmpty(text)) {
            Log.v(TAG, "No hint using text: " + text + " and class " + className);
        }
        return null;
    }

    /**比對有設定autofillHints的元件,有時候可能其他開發者會隨便亂設定的話必須也能夠抓取到*/
    @Nullable
    protected String inferHint(AssistStructure.ViewNode node, @Nullable String actualHint) {
        if (actualHint == null) return null;

        String hint = actualHint.toLowerCase();
        //忽視掉 'label/container' 的提示
        if (hint.contains("label") || hint.contains("container")) {
            return null;
        }
        //抓取提示為password
        if (hint.contains("password")) return View.AUTOFILL_HINT_PASSWORD;
        //抓取提示為username或者login+id的組合
        if (hint.contains("username")
                || (hint.contains("login") && hint.contains("id")))
            return View.AUTOFILL_HINT_USERNAME;
        //以下類推
        if (hint.contains("email")) return View.AUTOFILL_HINT_EMAIL_ADDRESS;
        if (hint.contains("name")) return View.AUTOFILL_HINT_NAME;
        if (hint.contains("phone")) return View.AUTOFILL_HINT_PHONE;
        //若元件狀態為啟用,且設定的Type不是NONE(意味著可能設定了不一樣的)
        if (node.isEnabled() && node.getAutofillType() != View.AUTOFILL_TYPE_NONE) {
            return actualHint;
        }
        return null;
    }

    /**↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑*/

    
    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 在詢問建議「是否儲存密碼」後的回調 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    /**當在完成登入後的填出視窗,若使用者選擇儲存的話便會來到這個回調*/
    @Override
    public void onSaveRequest(SaveRequest request, SaveCallback callback) {
       
    }
    
}

 

簡單解釋壹下邏輯。首先剛剛有說過addAutofillableFields就是觸發到的回調

其他幾個副程式的功能也有寫了,我也不在廢話

不過對於整體程式來說我有標線起來的部分就是一定得提一下的內容

 

首先是

AssistStructure.ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();

這部分適用於抓取整個畫面的元件

 

再來

AutofillId id = node.getAutofillId();

則是在找到有autofill標籤的元件之下,再進一步取得他的id之用

我們在寫程式時經常有findViewById的寫法,而這裡相當於就是取他的ID之用

 

最後如果放一個Log的話就會看到以下內容(Log我幫你放好了)

截圖 2023-05-21 上午12.30.52

 

再來我們要來做第二部分,注意,在接下來的內容第一部分寫過的程式碼我會略過

public class MyAutofill extends AutofillService {

    private final static String TAG = AutofillService.class.getSimpleName();

    @Override
    public void onConnected() {
        super.onConnected();
       //可以在這裡做初始設定

    }

    //當介面被觸發時回調這裡
    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
                              FillCallback callback) {

        /**
         *
         *          *
         *
         * */

        // 新增回調
        FillResponse response;
        response = createResponse(this, fields);

        callback.onSuccess(response);
    }

    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    /**
     *
     *      *
     *
     * */

    /**↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑*/

    /**新增一個對於觸發物件後的回調*/
    static FillResponse createResponse(@NonNull Context context,
                                       @NonNull ArrayMap<String, AutofillId> fields) {
        String packageName = context.getPackageName();
        FillResponse.Builder response = new FillResponse.Builder();
        //TODO 可以在這邊做取出資料的操作,無論你是用Database或者sharePreference揭示在這邊做取出資料並操作

        //本範例在這邊做假資料
        ArrayList<SaveData> arrayList = new ArrayList<>();
        arrayList.add(new SaveData("Noah","123456"));
        arrayList.add(new SaveData("Edis","654321"));

        for (int i = 0; i < arrayList.size(); i++) {
            Dataset unlockedDataset = newUnlockedDataset(fields, packageName, arrayList.get(i));
            response.addDataset(unlockedDataset);
        }

        //如果使用者輸入了沒被儲存過的密碼,則彈出視窗主動詢問要不要儲存
        Collection<AutofillId> ids = fields.values();
        AutofillId[] requiredIds = new AutofillId[ids.size()];
        ids.toArray(requiredIds);
        response.setSaveInfo(
                new SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_GENERIC, requiredIds).build());

        return response.build();
    }

    /**建立下拉式介面的內容與要填入的設定*/
    static Dataset newUnlockedDataset(@NonNull Map<String, AutofillId> fields,
                                      @NonNull String packageName, SaveData data) {
        Dataset.Builder dataset = new Dataset.Builder();
        //遍歷元件
        for (Map.Entry<String, AutofillId> field : fields.entrySet()) {
            String hint = field.getKey();
            AutofillId id = field.getValue();
            //如果介面的hintusername,則填入Name,否則填入Psw(通常這裡是非username則一定是password            String value = hint.contains("username")? data.getName() : data.getPsw();
            //這裡是下拉的提示介面,一般來說只會顯示帳號不會顯示密碼
            String displayValue = hint.contains("password") ? data.getName() : "無使用者名稱";

            //下拉提示的介面
            RemoteViews presentation =
                    new RemoteViews(packageName, R.layout.user_suggestion_item);
            presentation.setTextViewText(R.id.user_suggestion_item, displayValue);
            presentation.setImageViewResource(R.id.icon, R.drawable.ic_baseline_key_24);

            //Autofill的要輸入的元件ID、要輸入的字以及下拉提示的介面
            dataset.setValue(id, AutofillValue.forText(value), presentation);
        }

        return dataset.build();
    }
    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 在詢問建議「是否儲存密碼」後的回調 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    /**當在完成登入後的填出視窗,若使用者選擇儲存的話便會來到這個回調*/
    @Override
    public void onSaveRequest(SaveRequest request, SaveCallback callback) {

    }

}

 

這部分的程式碼,首先是field.getKey()的部分

這裡頭上的迴圈基本上就是再次遍歷我們已經儲存的資料(我這裡是做假資料)

然後依據這些資料一一地去帶入到View裏面

再來是

RemoteViews presentation

這裡就是那個下拉式選單的介面,母需多談

 

然後是

Collection<AutofillId> ids = fields.values();

這部分就是

截圖 2023-05-21 上午12.06.22

囉XD

 

最後一切完成後,我們將這些東西包裝近DateSet類,然後在callback.onSuccess(response);做Callback

好,做到這裡也可以跑一次,這時候應該畫面中就會出現填入建議囉:D

 

最後來做儲存回調,基本就是在的觸發onSaveRequest部分

public class MyAutofill extends AutofillService {

    private final static String TAG = AutofillService.class.getSimpleName();

    @Override
    public void onConnected() {
        super.onConnected();
       //可以在這裡做初始設定

    }

    //當介面被觸發時回調這裡
    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
                              FillCallback callback) {

        /**
         *
         *
         *          *
         *
         * */
    }

    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    /**
     *
     *
     *      *
     *
     * */

    /**↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑*/

    /**
     * 
     * 
     *      * 
     * 
     * */
    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 在詢問建議「是否儲存密碼」後的回調 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    /**當在完成登入後的填出視窗,若使用者選擇儲存的話便會來到這個回調*/
    @Override
    public void onSaveRequest(SaveRequest request, SaveCallback callback) {
        SaveData data = null;
        List<FillContext> fillContexts = request.getFillContexts();
        for (FillContext context:fillContexts) {
            ArrayMap<String, String> fields = getInputWord(context.getStructure());
            data = new SaveData(fields.get("username"),fields.get("password"));
        }
        if (data != null){
            //TODO 將介面目前的輸入儲存起來
            Log.d(TAG, "username: "+data.getName()+", password: "+data.getPsw());
        }

        callback.onSuccess();
    }
    /**遍歷畫面中的元件,取得hint元件中所輸入的內容*/
    private ArrayMap<String, String> getInputWord(@NonNull AssistStructure structure) {
        ArrayMap<String, String> fields = new ArrayMap<>();
        //取得該畫面中的節點數量
        int nodes = structure.getWindowNodeCount();
        for (int i = 0; i < nodes; i++) {
            AssistStructure.ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
            addInputArray(fields, node);

        }
        return fields;
    }
    private void addInputArray(@NonNull Map<String, String> fields,
                                       @NonNull AssistStructure.ViewNode node) {
        String hint = getHint(node);
        if (hint != null) {
            AutofillId id = node.getAutofillId();
            if (!fields.containsKey(hint)) {
                Log.e(TAG, "已找到有設定autofillHints的元件,hint '" + hint + "' 設定ID " + id);
                fields.put(hint, node.getText().toString());
            }
        }
        //利用遞歸法持續遍歷整個介面
        int childrenSize = node.getChildCount();
        for (int i = 0; i < childrenSize; i++) {
            addInputArray(fields, node.getChildAt(i));
        }
    }
}

 

在這個部分我們拿取到request.getFillContexts()後進入到遍歷流程

原則上跟前面一樣,就是便利所有元件在抓出剛剛的那兩個EditText元件

 

不過這裡跟前面不同的是我採用了node.getText().toString()抓到了剛才的輸入

最後雖然我沒有寫儲存機制,不過在我TODO的地方有標註這個地方可以做儲存

 

而如果有然願意幫我寫儲存的話,歡迎提Request哦哈哈哈

好啦,那麼就照慣例PO全程式

 

MyAutofill.java

package com.noahliu.autofillexample.Service;

import android.app.assist.AssistStructure;
import android.content.Context;
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.SaveInfo;
import android.service.autofill.SaveRequest;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.Log;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.RemoteViews;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.noahliu.autofillexample.R;
import com.noahliu.autofillexample.SaveData;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;

public class MyAutofill extends AutofillService {

    private final static String TAG = AutofillService.class.getSimpleName();

    @Override
    public void onConnected() {
        super.onConnected();
       //可以在這裡做初始設定

    }

    //當介面被觸發時回調這裡
    @Override
    public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal,
                              FillCallback callback) {

        // 取得介面上所有的元件,並以陣列輸出有設定"hint"的元件之id
        List<FillContext> fillContexts = request.getFillContexts();
        AssistStructure structure = fillContexts.get(fillContexts.size() - 1).getStructure();
        //過濾出有設定"hint"屬性的元件,並輸出成陣列
        ArrayMap<String, AutofillId> fields = getAutofillableFields(structure);
        Log.e(TAG, "在目前介面中有被設定的元件列表:\n" + fields);

        // 新增回調
        FillResponse response;
        response = createResponse(this, fields);

        callback.onSuccess(response);
    }

    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    @NonNull
    private ArrayMap<String, AutofillId> getAutofillableFields(@NonNull AssistStructure structure) {
        ArrayMap<String, AutofillId> fields = new ArrayMap<>();
        //取得該畫面中的節點數量
        int nodes = structure.getWindowNodeCount();
        for (int i = 0; i < nodes; i++) {
            AssistStructure.ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
            addAutofillableFields(fields, node);
        }
        return fields;
    }

    private void addAutofillableFields(@NonNull Map<String, AutofillId> fields,
                                       @NonNull AssistStructure.ViewNode node) {
        String hint = getHint(node);
        if (hint != null) {
            AutofillId id = node.getAutofillId();
            if (!fields.containsKey(hint)) {
                Log.e(TAG, "已找到有設定autofillHints的元件,hint '" + hint + "' 設定ID " + id);
                fields.put(hint, id);
            } else {
                //如果有重複設定的元件的話將會到這邊
                //比方說已經有檢測到某元件在前面被設定過的話,後面被偵測到的元件將會被無視掉
                Log.d(TAG, "忽視掉有重複設定autofillHints的元件,hint '" + hint + "' 設定ID " + id);
            }
        }
        //利用遞歸法持續遍歷整個介面
        int childrenSize = node.getChildCount();
        for (int i = 0; i < childrenSize; i++) {
            addAutofillableFields(fields, node.getChildAt(i));
        }
    }

    @Nullable
    protected String getHint(@NonNull AssistStructure.ViewNode node) {

        String viewHint = node.getHint();
        String hint = inferHint(node, viewHint);

        if (hint != null) {
            Log.e(TAG, "找到元件, hint(" + viewHint + "): " + hint);
            return hint;
        } else if (!TextUtils.isEmpty(viewHint)) {
            Log.d(TAG, "無提示元件: " + viewHint);
        }
        String resourceId = node.getIdEntry();
        hint = inferHint(node, resourceId);
        if (hint != null) {
            Log.e(TAG, "找到有提示的元件ID: (" + resourceId + "): " + hint);
            return hint;
        } else if (!TextUtils.isEmpty(resourceId)) {
            Log.d(TAG, "無使用提示的元件ID: " + resourceId);
        }

        //這邊是官方給的示範,意味著你可以僅過濾某個Class、某個字元或者只去過濾EditText之類的
        CharSequence text = node.getText();
        CharSequence className = node.getClassName();
        if (text != null && className != null && className.toString().contains("EditText")) {
            hint = inferHint(node, text.toString());
            if (hint != null) {
                Log.d(TAG, "Found hint using text(" + text + "): " + hint);
                return hint;
            }
        } else if (!TextUtils.isEmpty(text)) {
            Log.v(TAG, "No hint using text: " + text + " and class " + className);
        }
        return null;
    }

    /**比對有設定autofillHints的元件,有時候可能其他開發者會隨便亂設定的話必須也能夠抓取到*/
    @Nullable
    protected String inferHint(AssistStructure.ViewNode node, @Nullable String actualHint) {
        if (actualHint == null) return null;

        String hint = actualHint.toLowerCase();
        //忽視掉 'label/container' 的提示
        if (hint.contains("label") || hint.contains("container")) {
            return null;
        }
        //抓取提示為password
        if (hint.contains("password")) return View.AUTOFILL_HINT_PASSWORD;
        //抓取提示為username或者login+id的組合
        if (hint.contains("username")
                || (hint.contains("login") && hint.contains("id")))
            return View.AUTOFILL_HINT_USERNAME;
        //以下類推
        if (hint.contains("email")) return View.AUTOFILL_HINT_EMAIL_ADDRESS;
        if (hint.contains("name")) return View.AUTOFILL_HINT_NAME;
        if (hint.contains("phone")) return View.AUTOFILL_HINT_PHONE;
        //若元件狀態為啟用,且設定的Type不是NONE(意味著可能設定了不一樣的)
        if (node.isEnabled() && node.getAutofillType() != View.AUTOFILL_TYPE_NONE) {
            return actualHint;
        }
        return null;
    }

    /**↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ 此處為過濾整個畫面,並取得有設定autofillHints屬性的元件 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑*/

    /**新增一個對於觸發物件後的回調*/
    static FillResponse createResponse(@NonNull Context context,
                                       @NonNull ArrayMap<String, AutofillId> fields) {
        String packageName = context.getPackageName();
        FillResponse.Builder response = new FillResponse.Builder();
        //TODO 可以在這邊做取出資料的操作,無論你是用Database或者sharePreference揭示在這邊做取出資料並操作

        //本範例在這邊做假資料
        ArrayList<SaveData> arrayList = new ArrayList<>();
        arrayList.add(new SaveData("Noah","123456"));
        arrayList.add(new SaveData("Edis","654321"));

        for (int i = 0; i < arrayList.size(); i++) {
            Dataset unlockedDataset = newUnlockedDataset(fields, packageName, arrayList.get(i));
            response.addDataset(unlockedDataset);
        }

        //如果使用者輸入了沒被儲存過的密碼,則彈出視窗主動詢問要不要儲存
        Collection<AutofillId> ids = fields.values();
        AutofillId[] requiredIds = new AutofillId[ids.size()];
        ids.toArray(requiredIds);
        response.setSaveInfo(
                new SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_GENERIC, requiredIds).build());

        return response.build();
    }

    /**建立下拉式介面的內容與要填入的設定*/
    static Dataset newUnlockedDataset(@NonNull Map<String, AutofillId> fields,
                                      @NonNull String packageName, SaveData data) {
        Dataset.Builder dataset = new Dataset.Builder();
        //遍歷元件
        for (Map.Entry<String, AutofillId> field : fields.entrySet()) {
            String hint = field.getKey();
            AutofillId id = field.getValue();
            //如果介面的hintusername,則填入Name,否則填入Psw(通常這裡是非username則一定是password            String value = hint.contains("username")? data.getName() : data.getPsw();
            //這裡是下拉的提示介面,一般來說只會顯示帳號不會顯示密碼
            String displayValue = hint.contains("password") ? data.getName() : "無使用者名稱";

            //下拉提示的介面
            RemoteViews presentation =
                    new RemoteViews(packageName, R.layout.user_suggestion_item);
            presentation.setTextViewText(R.id.user_suggestion_item, displayValue);
            presentation.setImageViewResource(R.id.icon, R.drawable.ic_baseline_key_24);

            //Autofill的要輸入的元件ID、要輸入的字以及下拉提示的介面
            dataset.setValue(id, AutofillValue.forText(value), presentation);
        }

        return dataset.build();
    }
    /**↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 在詢問建議「是否儲存密碼」後的回調 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓*/
    /**當在完成登入後的填出視窗,若使用者選擇儲存的話便會來到這個回調*/
    @Override
    public void onSaveRequest(SaveRequest request, SaveCallback callback) {
        SaveData data = null;
        List<FillContext> fillContexts = request.getFillContexts();
        for (FillContext context:fillContexts) {
            ArrayMap<String, String> fields = getInputWord(context.getStructure());
            data = new SaveData(fields.get("username"),fields.get("password"));
        }
        if (data != null){
            //TODO 將介面目前的輸入儲存起來
            Log.d(TAG, "username: "+data.getName()+", password: "+data.getPsw());
        }

        callback.onSuccess();
    }
    /**遍歷畫面中的元件,取得hint元件中所輸入的內容*/
    private ArrayMap<String, String> getInputWord(@NonNull AssistStructure structure) {
        ArrayMap<String, String> fields = new ArrayMap<>();
        //取得該畫面中的節點數量
        int nodes = structure.getWindowNodeCount();
        for (int i = 0; i < nodes; i++) {
            AssistStructure.ViewNode node = structure.getWindowNodeAt(i).getRootViewNode();
            addInputArray(fields, node);

        }
        return fields;
    }
    private void addInputArray(@NonNull Map<String, String> fields,
                                       @NonNull AssistStructure.ViewNode node) {
        String hint = getHint(node);
        if (hint != null) {
            AutofillId id = node.getAutofillId();
            if (!fields.containsKey(hint)) {
                Log.e(TAG, "已找到有設定autofillHints的元件,hint '" + hint + "' 設定ID " + id);
                fields.put(hint, node.getText().toString());
            }
        }
        //利用遞歸法持續遍歷整個介面
        int childrenSize = node.getChildCount();
        for (int i = 0; i < childrenSize; i++) {
            addInputArray(fields, node.getChildAt(i));
        }
    }
}

 


 

這篇文章算是我近期寫得比較拼的一篇XD

因為前面說過這功能不常見,所以我想這篇應該很少人會看吧(嘆氣

算是CP值超低的一篇文章了哈哈

 

不過,為了貫徹我什麼都寫得最高指導原則,我依然會持續寫文...即使冷門哈哈

好啦,那這篇文章就到這,希望本文對你有幫助哦

TK2

arrow
arrow

    碼農日常 發表在 痞客邦 留言(2) 人氣()