Compare commits
3 Commits
52cf570f6c
...
e93cc36d33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e93cc36d33 | ||
|
|
6fa5a6c175 | ||
|
|
5478355435 |
64
Assets/Editor/EnableHideMobileInput.cs
Normal file
64
Assets/Editor/EnableHideMobileInput.cs
Normal file
@ -0,0 +1,64 @@
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 批量勾选项目中所有 Prefab 里 InputField 的 Hide Mobile Input 选项
|
||||
/// 菜单:Tools/UI/勾选所有输入框的 Hide Mobile Input
|
||||
/// 说明:
|
||||
/// 1. shouldHideMobileInput 属性在编辑器下(非移动平台)getter 恒返回 true,
|
||||
/// 因此直接操作序列化字段 m_HideMobileInput(inspector 勾选框实际写入的字段)
|
||||
/// 2. 使用 LoadPrefabContents/SaveAsPrefabAsset 打开并保存,
|
||||
/// 确保 YAML 真正写盘(LoadAssetAtPath + SaveAssets 不够可靠)
|
||||
/// </summary>
|
||||
public static class EnableHideMobileInput
|
||||
{
|
||||
[MenuItem("Tools/UI/勾选所有输入框的 Hide Mobile Input")]
|
||||
public static void EnableAll()
|
||||
{
|
||||
var log = new StringBuilder();
|
||||
int files = 0, fields = 0;
|
||||
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }))
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var contents = PrefabUtility.LoadPrefabContents(path);
|
||||
try
|
||||
{
|
||||
int changed = 0;
|
||||
foreach (var field in contents.GetComponentsInChildren<InputField>(true))
|
||||
{
|
||||
var so = new SerializedObject(field);
|
||||
var prop = so.FindProperty("m_HideMobileInput");
|
||||
if (prop != null && !prop.boolValue)
|
||||
{
|
||||
prop.boolValue = true;
|
||||
so.ApplyModifiedProperties();
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed > 0)
|
||||
{
|
||||
PrefabUtility.SaveAsPrefabAsset(contents, path);
|
||||
files++;
|
||||
fields += changed;
|
||||
log.AppendLine($"{path}:{changed} 个");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
PrefabUtility.UnloadPrefabContents(contents);
|
||||
}
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
if (files == 0)
|
||||
Debug.Log("所有输入框的 Hide Mobile Input 均已勾选,无需修改。");
|
||||
else
|
||||
Debug.Log($"处理完成:{files} 个 Prefab 共 {fields} 个输入框已勾选。\n{log}");
|
||||
}
|
||||
}
|
||||
11
Assets/Editor/EnableHideMobileInput.cs.meta
Normal file
11
Assets/Editor/EnableHideMobileInput.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aad443f4ae341494abea2d472c16af32
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
323
Assets/Plugins/Android/AutofillBridge.java
Normal file
323
Assets/Plugins/Android/AutofillBridge.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
Assets/Plugins/Android/AutofillBridge.java.meta
Normal file
32
Assets/Plugins/Android/AutofillBridge.java.meta
Normal 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:
|
||||
@ -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.**$* { *; }
|
||||
|
||||
@ -751,7 +751,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 20
|
||||
m_OnSubmit:
|
||||
@ -858,12 +858,6 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
cameraPreview: {fileID: 8124639261302109470}
|
||||
scanType: 1
|
||||
enableUpscale: 1
|
||||
upscaleFactor: 2
|
||||
scanResolutionWidth: 1080
|
||||
useAsyncScan: 1
|
||||
enableAutoFocus: 1
|
||||
focusInterval: 1
|
||||
previewUpdateInterval: 0.03
|
||||
--- !u!1 &4591191622053940793
|
||||
GameObject:
|
||||
|
||||
@ -634,7 +634,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
@ -950,7 +950,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
@ -1325,7 +1325,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
@ -413,7 +413,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 16
|
||||
m_OnSubmit:
|
||||
|
||||
@ -1211,7 +1211,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
@ -1047,7 +1047,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
@ -2295,8 +2295,6 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
cameraPreview: {fileID: 7322899035469920849}
|
||||
scanType: 0
|
||||
useAsyncScan: 1
|
||||
centerCropRatio: 0.4
|
||||
previewUpdateInterval: 0.03
|
||||
--- !u!114 &6470133651671008070
|
||||
MonoBehaviour:
|
||||
@ -5611,7 +5609,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
@ -7908,7 +7908,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
@ -19410,7 +19410,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 16
|
||||
m_OnSubmit:
|
||||
|
||||
@ -5197,7 +5197,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 7
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 5
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
@ -8517,7 +8517,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 7
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 5
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
@ -10644,7 +10644,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 16
|
||||
m_OnSubmit:
|
||||
@ -11063,7 +11063,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 4
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 1
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
@ -13816,7 +13816,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
@ -2770,7 +2770,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 1
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 300
|
||||
m_OnSubmit:
|
||||
|
||||
@ -1792,7 +1792,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 0
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 0
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
@ -2121,7 +2121,7 @@ MonoBehaviour:
|
||||
m_AsteriskChar: 42
|
||||
m_KeyboardType: 4
|
||||
m_LineType: 0
|
||||
m_HideMobileInput: 0
|
||||
m_HideMobileInput: 1
|
||||
m_CharacterValidation: 1
|
||||
m_CharacterLimit: 0
|
||||
m_OnSubmit:
|
||||
|
||||
184
Assets/Scripts/UI/Components/AutofillBridge.cs
Normal file
184
Assets/Scripts/UI/Components/AutofillBridge.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/UI/Components/AutofillBridge.cs.meta
Normal file
11
Assets/Scripts/UI/Components/AutofillBridge.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 811b32b915cfad14ab42718829acd9c4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
// 使用自定义错误版本,在输入框下方显示错误
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user