feat(login): 新增谷歌密码自动填充的隐形原生输入桥

新增 AutofillBridge.java:隐形原生 EditText(低透明度伪可见)设置 autofillHints,支持填充建议与保存提示,commit 采用反射调用规避符号解析问题

新增 AutofillBridge.cs:拦截 InputField 点击改由原生框承接输入,屏幕坐标换算、文本实时同步回显;登录邮箱/密码框接入

登录点击时先 commit 触发保存密码再隐藏;失焦/键盘完成时软隐藏(视图保留树上供快照采集),hide 防重入修复 NPE;proguard-user.txt 添加 keep 规则防 R8 剥离

状态:待真机验证填充与保存提示,已加 autofill 会话诊断日志
This commit is contained in:
“虞渠成” 2026-09-11 09:46:33 +08:00
parent 5478355435
commit 6fa5a6c175
7 changed files with 569 additions and 0 deletions

View File

@ -0,0 +1,323 @@
package com.photonmatrix.autofill;
import android.app.Activity;
import android.graphics.Color;
import android.text.Editable;
import android.text.InputFilter;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.inputmethod.InputMethodManager;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.FrameLayout;
import com.unity3d.player.UnityPlayer;
/**
* 隐形原生输入桥Android 谷歌密码自动填充
*
* 原理 Unity InputField 的屏幕位置上叠加一个视觉隐形低透明度无背景的原生 EditText
* 通过 autofillHints 让谷歌密码管理器识别该输入框从而提供填充建议和"保存密码"提示
* 输入内容通过 TextWatcher 实时回传给 Unity 侧同步显示
*/
public class AutofillBridge {
private static final String TAG = "[AUTOFILL-BRIDGE]";
private static final String RECEIVER_GO_NAME = "AutofillBridgeReceiver";
private static EditText sEditText;
private static EditText sCompanionUsername; // 密码页配套的隐形用户名框用于成对保存账号密码
/**
* 显示隐形输入桥
* @param hint autofillHints "emailAddress" / "username" / "password"
* @param initialText 初始文本Unity InputField 当前值
* @param x 屏幕像素坐标Android 屏幕坐标系y 从顶部开始
* @param y 同上
* @param w 像素
* @param h 像素
* @param maxLength 最大字符数0 表示不限制
* @param companionUsername 密码输入时配套的账号非空则额外挂一个隐形用户名框用于成对保存
*/
public static void show(final String hint, final String initialText,
final int x, final int y, final int w, final int h,
final int maxLength, final String companionUsername) {
final Activity activity = UnityPlayer.currentActivity;
if (activity == null || activity.isFinishing()) {
return;
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
showInternal(activity, hint, initialText, x, y, w, h, maxLength, companionUsername);
}
});
}
private static void showInternal(Activity activity, String hint, String initialText,
int x, int y, int w, int h,
int maxLength, String companionUsername) {
try {
hideInternal(activity, false);
final EditText editText = new EditText(activity);
editText.setSingleLine(true);
editText.setBackground(null); // 去背景视觉隐形
editText.setAlpha(0.08f); // 低透明度"伪可见"完全隐藏时 autofill 服务不生效
editText.setHint(null);
editText.setTextColor(Color.TRANSPARENT);
editText.setCursorVisible(false);
// 输入类型与 autofillHints
editText.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);
editText.setAutofillHints(hint);
if ("password".equals(hint)) {
editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
editText.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE);
} else if ("emailAddress".equals(hint)) {
editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
editText.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_NEXT);
} else {
editText.setInputType(InputType.TYPE_CLASS_TEXT);
editText.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE);
}
if (maxLength > 0) {
editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(maxLength)});
}
if (initialText != null && !initialText.isEmpty()) {
editText.setText(initialText);
editText.setSelection(initialText.length());
}
// 文本变化实时回传 Unity autofill 填充的值
editText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
if (sEditText == editText) {
UnityPlayer.UnitySendMessage(RECEIVER_GO_NAME, "OnNativeTextChanged", s.toString());
}
}
});
// IME 完成键软隐藏保留视图供登录时 commit 快照采集
editText.setOnEditorActionListener(new android.widget.TextView.OnEditorActionListener() {
@Override
public boolean onEditorAction(android.widget.TextView v, int actionId, android.view.KeyEvent event) {
softHide();
return false;
}
});
// 失焦时软隐藏兜底isFocusable 检查防递归
editText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
if (!hasFocus && sEditText == v && v.isFocusable()) {
softHide();
}
}
});
// 位置 Unity InputField 屏幕矩形重合
ViewGroup root = (ViewGroup) activity.getWindow().getDecorView();
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
Math.max(w, 1), Math.max(h, 1));
lp.gravity = Gravity.TOP | Gravity.START;
root.addView(editText, lp);
editText.setTranslationX(x);
editText.setTranslationY(y);
root.requestLayout();
sEditText = editText;
// 密码场景配套一个隐形"用户名"让保存的凭据账号密码成对
if (companionUsername != null && !companionUsername.isEmpty()) {
EditText companion = new EditText(activity);
companion.setSingleLine(true);
companion.setBackground(null);
companion.setAlpha(0.03f);
companion.setTextColor(Color.TRANSPARENT);
companion.setCursorVisible(false);
companion.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);
companion.setAutofillHints(View.AUTOFILL_HINT_USERNAME);
companion.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
companion.setText(companionUsername);
companion.setFocusable(false); // 不参与焦点/填充请求仅参与保存快照
companion.setClickable(false);
ViewGroup.LayoutParams clp = new FrameLayout.LayoutParams(Math.max(w, 1), Math.max(h, 1));
root.addView(companion, clp);
companion.setTranslationX(x);
companion.setTranslationY(y);
sCompanionUsername = companion;
}
// 请求焦点并弹出软键盘
editText.setFocusableInTouchMode(true);
editText.requestFocus();
InputMethodManager imm = (InputMethodManager) activity.getSystemService(Activity.INPUT_METHOD_SERVICE);
if (imm != null) {
editText.postDelayed(new Runnable() {
@Override
public void run() {
if (sEditText == editText) {
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
}
}
}, 100);
}
// autofill 会话诊断确认系统是否建立了填充会话1.5 秒后打印状态
editText.postDelayed(new Runnable() {
@Override
public void run() {
try {
if (sEditText != editText) return;
java.lang.reflect.Method getter = activity.getClass().getMethod("getAutofillManager");
Object afm = getter.invoke(activity);
if (afm != null) {
Object enabled = afm.getClass().getMethod("isEnabled").invoke(afm);
Object filled = afm.getClass().getMethod("hasFilledSession").invoke(afm);
android.util.Log.d(TAG, "autofill 诊断: enabled=" + enabled
+ ", hasFilledSession(会话已建立)=" + filled
+ ", viewFocused=" + editText.isFocused()
+ ", viewShown=" + editText.isShown()
+ ", hints=" + java.util.Arrays.toString(editText.getAutofillHints()));
} else {
android.util.Log.d(TAG, "autofill 诊断: getAutofillManager() 为 null");
}
} catch (Throwable t) {
android.util.Log.e(TAG, "autofill 诊断失败", t);
}
}
}, 1500);
} catch (Throwable t) {
android.util.Log.e(TAG, "show failed", t);
}
}
/**
* 隐藏输入桥
* @param commitToAutofill 隐藏前是否触发 autofill 保存
*/
public static void hide(final boolean commitToAutofill) {
final Activity activity = UnityPlayer.currentActivity;
if (activity == null) {
return;
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
hideInternal(activity, commitToAutofill);
}
});
}
private static void hideInternal(Activity activity, boolean commitToAutofill) {
try {
// commit 必须在移除视图之前保存快照采集需要输入框仍在视图树中
if (commitToAutofill) {
commitInternal(activity);
}
// 先置空引用再移除防止移除过程中触发的失焦回调重入 hide 导致 NPE
EditText editText = sEditText;
EditText companion = sCompanionUsername;
sEditText = null;
sCompanionUsername = null;
InputMethodManager imm = (InputMethodManager) activity.getSystemService(Activity.INPUT_METHOD_SERVICE);
View focus = activity.getCurrentFocus();
if (imm != null && focus != null) {
imm.hideSoftInputFromWindow(focus.getWindowToken(), 0);
}
ViewGroup root = (ViewGroup) activity.getWindow().getDecorView();
if (editText != null) {
root.removeView(editText);
}
if (companion != null) {
root.removeView(companion);
}
} catch (Throwable t) {
android.util.Log.e(TAG, "hide failed", t);
}
}
/**
* 软隐藏收起键盘并解除交互不可聚焦/不可点击点击穿透回 Unity
* 但视图保留在视图树上登录 commit autofill 保存快照仍能采集到输入字段
*/
public static void softHide() {
final Activity activity = UnityPlayer.currentActivity;
if (activity == null) {
return;
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
softHideInternal(activity);
}
});
}
private static void softHideInternal(Activity activity) {
try {
EditText editText = sEditText;
if (editText == null) {
return;
}
InputMethodManager imm = (InputMethodManager) activity.getSystemService(Activity.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.hideSoftInputFromWindow(editText.getWindowToken(), 0);
}
editText.setFocusable(false);
editText.setFocusableInTouchMode(false);
editText.setClickable(false);
editText.setLongClickable(false);
editText.clearFocus();
} catch (Throwable t) {
android.util.Log.e(TAG, "soft hide failed", t);
}
}
/**
* 触发 autofill 保存流程登录成功时调用弹出"保存密码"提示
* 注意需在输入桥仍挂载在视图树时调用登录点击时先 commit hide
*/
public static void commit() {
final Activity activity = UnityPlayer.currentActivity;
if (activity == null) {
return;
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
commitInternal(activity);
}
});
}
private static void commitInternal(Activity activity) {
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// 反射调用 getAutofillManager().commit()避免部分 android.jar 变体缺少该符号导致编译失败
java.lang.reflect.Method getter = activity.getClass().getMethod("getAutofillManager");
final Object afm = getter.invoke(activity);
if (afm != null) {
java.lang.reflect.Method commit = afm.getClass().getMethod("commit");
commit.invoke(afm);
}
}
} catch (Throwable t) {
android.util.Log.e(TAG, "commit failed", t);
}
}
}

View File

@ -0,0 +1,32 @@
fileFormatVersion: 2
guid: 981a182736820804d8310023dc57abc7
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Android: Android
second:
enabled: 1
settings: {}
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:

View File

@ -4,6 +4,9 @@
# 保留 FilePicker 插件类(通过 AndroidJavaClass 反射调用R8 无法检测到使用)
-keep class com.unity.filepicker.** { *; }
# 保留谷歌自动填充隐形输入桥(通过 AndroidJavaClass 反射调用R8 无法检测到使用)
-keep class com.photonmatrix.autofill.** { *; }
# 保留 ffmpeg-kit 全部类(R8 混淆后 ffmpeg-kit 内部 native 调用会失效)
-keep class com.arthenica.ffmpegkit.** { *; }
-keep class com.arthenica.ffmpegkit.**$* { *; }

View File

@ -0,0 +1,184 @@
using UnityEngine;
using UnityEngine.UI;
namespace Kill.UI.Components
{
/// <summary>
/// autofill 语义类型(对应 Android autofillHints
/// </summary>
public enum AutofillHint
{
EmailAddress, // emailAddress
Username, // username
Password, // password
}
/// <summary>
/// 隐形原生输入桥Android 谷歌密码自动填充)
///
/// 用法AutofillBridge.Attach(inputField, AutofillHint.Password)
/// Attach 后会在 InputField 上叠一层透明按钮拦截点击:点击不再激活 Unity 自带键盘,
/// 而是在 InputField 的屏幕位置弹出带 autofillHints 的隐形原生 EditText
/// 由谷歌密码管理器提供填充建议;输入内容实时同步回 Unity InputField 显示。
/// 登录成功时调用 Commit() 触发"保存密码"提示。
/// </summary>
public static class AutofillBridge
{
private const string JavaClass = "com.photonmatrix.autofill.AutofillBridge";
private const string ReceiverGoName = "AutofillBridgeReceiver";
private static AndroidJavaClass sJava;
private static GameObject sReceiverGo;
private static InputField sActiveField;
/// <summary>密码页配套的账号(保存凭据时账号密码成对)</summary>
public static string CompanionUsername { get; set; } = "";
public static bool Supported =>
!Application.isEditor && Application.platform == RuntimePlatform.Android;
/// <summary>
/// 绑定 InputField叠加透明按钮拦截点击改由原生输入桥承接
/// </summary>
public static void Attach(InputField field, AutofillHint hint)
{
if (field == null || !Supported) return;
EnsureReceiver();
var blockerGo = new GameObject(field.name + "_AutofillBlocker", typeof(RectTransform), typeof(Image), typeof(Button));
var blockerRt = blockerGo.GetComponent<RectTransform>();
var fieldRt = field.transform as RectTransform;
blockerRt.SetParent(fieldRt.parent, false);
// 复制 InputField 的布局,完全重合
blockerRt.anchorMin = fieldRt.anchorMin;
blockerRt.anchorMax = fieldRt.anchorMax;
blockerRt.pivot = fieldRt.pivot;
blockerRt.anchoredPosition = fieldRt.anchoredPosition;
blockerRt.sizeDelta = fieldRt.sizeDelta;
blockerRt.localScale = Vector3.one;
blockerRt.SetAsLastSibling(); // 保持在 InputField 之上,优先接收点击
var img = blockerGo.GetComponent<Image>();
img.color = new Color(0, 0, 0, 0); // 全透明,仅用于接收射线
var btn = blockerGo.GetComponent<Button>();
btn.transition = Selectable.Transition.None;
btn.onClick.AddListener(() => Show(field, hint));
}
/// <summary>
/// 显示隐形原生输入框(一般由 Attach 的拦截按钮触发)
/// </summary>
public static void Show(InputField field, AutofillHint hint)
{
if (field == null || !Supported) return;
EnsureReceiver();
GetScreenRect(field, out int x, out int y, out int w, out int h);
sActiveField = field;
GetJava().CallStatic("show",
ToHintString(hint),
field.text ?? "",
x, y, w, h,
field.characterLimit,
CompanionUsername ?? "");
}
/// <summary>隐藏输入桥(切换页面时调用)</summary>
public static void Hide()
{
if (!Supported) return;
sActiveField = null;
GetJava().CallStatic("hide", false);
}
/// <summary>
/// 触发 autofill 保存流程(弹出"保存密码"提示)
/// 登录点击时调用:先 Commit 再 Hide
/// </summary>
public static void Commit()
{
if (!Supported) return;
GetJava().CallStatic("commit");
}
/// <summary>提交并隐藏(登录按钮常用组合)</summary>
public static void CommitAndHide()
{
if (!Supported) return;
sActiveField = null;
GetJava().CallStatic("hide", true);
}
/// <summary>原生文本变化回调Java 端 UnitySendMessage 进入)</summary>
internal static void DispatchNativeText(string text)
{
if (sActiveField != null)
sActiveField.text = text;
}
private static AndroidJavaClass GetJava()
{
if (sJava == null)
sJava = new AndroidJavaClass(JavaClass);
return sJava;
}
private static void EnsureReceiver()
{
if (sReceiverGo != null) return;
var existing = GameObject.Find(ReceiverGoName);
sReceiverGo = existing != null ? existing : new GameObject(ReceiverGoName);
if (existing == null)
sReceiverGo.AddComponent<AutofillBridgeReceiver>();
}
/// <summary>
/// InputField 的屏幕像素矩形,并转换为 Android 坐标系原点左上、y 向下)
/// </summary>
private static void GetScreenRect(InputField field, out int x, out int y, out int w, out int h)
{
var rt = field.transform as RectTransform;
var canvas = field.GetComponentInParent<Canvas>();
Camera cam = null;
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay)
cam = canvas.worldCamera;
var corners = new Vector3[4];
rt.GetWorldCorners(corners); // [0]左下 [2]右上
Vector2 p0 = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 p2 = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
int minX = Mathf.RoundToInt(Mathf.Min(p0.x, p2.x));
int maxX = Mathf.RoundToInt(Mathf.Max(p0.x, p2.x));
int minY = Mathf.RoundToInt(Mathf.Min(p0.y, p2.y));
int maxY = Mathf.RoundToInt(Mathf.Max(p0.y, p2.y));
x = minX;
w = maxX - minX;
h = maxY - minY;
// Unity y 向上 → Android y 向下
y = Screen.height - maxY;
}
private static string ToHintString(AutofillHint hint)
{
switch (hint)
{
case AutofillHint.Password: return "password";
case AutofillHint.Username: return "username";
default: return "emailAddress";
}
}
}
/// <summary>
/// Java 端 UnitySendMessage 的接收者GameObject 名固定为 AutofillBridgeReceiver
/// </summary>
public class AutofillBridgeReceiver : MonoBehaviour
{
public void OnNativeTextChanged(string text) => AutofillBridge.DispatchNativeText(text);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 811b32b915cfad14ab42718829acd9c4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -27,6 +27,9 @@ namespace Kill.UI.Pages
public Button privacyLinkBtn;
protected override void OnInitialize()
{
// 谷歌密码自动填充:邮箱框改由隐形原生输入桥承接
AutofillBridge.Attach(emailInput, AutofillHint.EmailAddress);
// 绑定按钮事件
if (loginBtn != null)
loginBtn.onClick.AddListener(OnLoginClick);
@ -64,6 +67,9 @@ namespace Kill.UI.Pages
privacyToggle.isOn = true;
errorText.text = "";
emailInput.text = "";
// 切回本页时收掉其他页面的原生输入桥
AutofillBridge.Hide();
}
private async void OnLoginClick()

View File

@ -18,6 +18,9 @@ namespace Kill.UI.Pages
protected override void OnInitialize()
{
// 谷歌密码自动填充:密码框改由隐形原生输入桥承接
AutofillBridge.Attach(passwordInput, AutofillHint.Password);
if (loginBtn != null)
loginBtn.onClick.AddListener(OnLoginClick);
@ -35,6 +38,10 @@ namespace Kill.UI.Pages
passwordInput.text = "";
if (errorText != null)
errorText.text = "";
// 配套账号用于保存凭据时账号密码成对
AutofillBridge.CompanionUsername = LoginPageCtrl.Instance.GetCurrentEmail();
AutofillBridge.Hide();
}
private void OnLoginClick()
@ -46,6 +53,9 @@ namespace Kill.UI.Pages
return;
}
// 触发谷歌密码管理器的"保存密码"提示(需在输入桥隐藏前调用)
AutofillBridge.CommitAndHide();
string email = LoginPageCtrl.Instance.GetCurrentEmail();
// 使用自定义错误版本,在输入框下方显示错误