diff --git a/Assets/Plugins/Android/AutofillBridge.java b/Assets/Plugins/Android/AutofillBridge.java new file mode 100644 index 0000000..e362194 --- /dev/null +++ b/Assets/Plugins/Android/AutofillBridge.java @@ -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); + } + } +} diff --git a/Assets/Plugins/Android/AutofillBridge.java.meta b/Assets/Plugins/Android/AutofillBridge.java.meta new file mode 100644 index 0000000..d25e84d --- /dev/null +++ b/Assets/Plugins/Android/AutofillBridge.java.meta @@ -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: diff --git a/Assets/Plugins/Android/proguard-user.txt b/Assets/Plugins/Android/proguard-user.txt index 0e2456a..2e2f83e 100644 --- a/Assets/Plugins/Android/proguard-user.txt +++ b/Assets/Plugins/Android/proguard-user.txt @@ -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.**$* { *; } diff --git a/Assets/Scripts/UI/Components/AutofillBridge.cs b/Assets/Scripts/UI/Components/AutofillBridge.cs new file mode 100644 index 0000000..3d3c9a7 --- /dev/null +++ b/Assets/Scripts/UI/Components/AutofillBridge.cs @@ -0,0 +1,184 @@ +using UnityEngine; +using UnityEngine.UI; + +namespace Kill.UI.Components +{ + /// + /// autofill 语义类型(对应 Android autofillHints) + /// + public enum AutofillHint + { + EmailAddress, // emailAddress + Username, // username + Password, // password + } + + /// + /// 隐形原生输入桥(Android 谷歌密码自动填充) + /// + /// 用法:AutofillBridge.Attach(inputField, AutofillHint.Password) + /// Attach 后会在 InputField 上叠一层透明按钮拦截点击:点击不再激活 Unity 自带键盘, + /// 而是在 InputField 的屏幕位置弹出带 autofillHints 的隐形原生 EditText, + /// 由谷歌密码管理器提供填充建议;输入内容实时同步回 Unity InputField 显示。 + /// 登录成功时调用 Commit() 触发"保存密码"提示。 + /// + 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; + + /// 密码页配套的账号(保存凭据时账号密码成对) + public static string CompanionUsername { get; set; } = ""; + + public static bool Supported => + !Application.isEditor && Application.platform == RuntimePlatform.Android; + + /// + /// 绑定 InputField:叠加透明按钮拦截点击,改由原生输入桥承接 + /// + 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(); + 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(); + img.color = new Color(0, 0, 0, 0); // 全透明,仅用于接收射线 + + var btn = blockerGo.GetComponent