Skip to content

Commit 3dc3ca6

Browse files
authored
Merge pull request #5 from iidamie/fix/android-input-broadcast-api
fix(android): add input broadcast APIs for mobile mods
2 parents b8cbdb2 + d95b75a commit 3dc3ca6

4 files changed

Lines changed: 374 additions & 9 deletions

File tree

‎StArray.ModManager.Android/Native/AndroidInput.cs‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public enum KeyAction
3333
Multiple = 2,
3434
}
3535

36-
/// <summary>触摸动作(主动作需用 ActionMask 提取)</summary>
36+
/// <summary>触摸动作(主动作需先去除指针索引位)</summary>
3737
public enum MotionAction
3838
{
3939
Down = 0,
@@ -43,11 +43,12 @@ public enum MotionAction
4343
Outside = 4,
4444
PointerDown = 5,
4545
PointerUp = 6,
46-
HoverEnter = 7,
47-
HoverMove = 8,
48-
HoverExit = 9,
49-
ButtonPress = 10,
50-
ButtonRelease = 11,
46+
HoverMove = 7,
47+
Scroll = 8,
48+
HoverEnter = 9,
49+
HoverExit = 10,
50+
ButtonPress = 11,
51+
ButtonRelease = 12,
5152
}
5253

5354
/// <summary>Meta 键状态</summary>
@@ -71,6 +72,13 @@ public static class MotionMask
7172
public const int PointerIndexShift = 8;
7273
}
7374

75+
// Keep integer helpers available to the timestamp broadcast path. The raw
76+
// action is read only once per native event, avoiding repeated P/Invoke
77+
// calls on the Android input thread.
78+
public const int ActionMask = MotionMask.Action;
79+
public const int ActionPointerIndexMask = MotionMask.PointerIndex;
80+
public const int ActionPointerIndexShift = MotionMask.PointerIndexShift;
81+
7482
// ======================== 不透明句柄结构体 ========================
7583

7684
/// <summary>不透明输入事件句柄</summary>
@@ -156,21 +164,39 @@ int handled
156164
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
157165
public static extern int AMotionEvent_getPointerId(IntPtr ev, int pointerIndex);
158166

167+
/// <summary>
168+
/// 事件发生时刻,单位纳秒,时钟源为 <c>CLOCK_MONOTONIC</c>。
169+
/// 该时间戳独立于渲染帧,可用于还原触摸真实发生时刻。
170+
/// </summary>
171+
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
172+
public static extern long AMotionEvent_getEventTime(IntPtr ev);
173+
174+
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
175+
public static extern long AMotionEvent_getDownTime(IntPtr ev);
176+
159177
// ======================== 辅助方法(扩展风格) ========================
160178

179+
/// <summary>从原始动作值取出主动作。</summary>
180+
public static MotionAction GetMainAction(int rawAction)
181+
=> (MotionAction)(rawAction & ActionMask);
182+
183+
/// <summary>从原始动作值取出指针索引。</summary>
184+
public static int GetPointerIndex(int rawAction)
185+
=> (rawAction & ActionPointerIndexMask) >> ActionPointerIndexShift;
186+
161187
/// <summary>获取触摸事件的主动作(去除指针索引)</summary>
162188
public static MotionAction GetMainAction(this IntPtr ev)
163189
{
164190
if (AInputEvent_getType(ev) != EventType.Motion)
165191
throw new InvalidOperationException("事件不是 Motion 类型");
166192
int raw = AMotionEvent_getAction(ev);
167-
return (MotionAction)(raw & MotionMask.Action);
193+
return GetMainAction(raw);
168194
}
169195

170196
/// <summary>获取触摸事件的指针索引(用于多点触控)</summary>
171197
public static int GetPointerIndex(this IntPtr ev)
172198
{
173199
int raw = AMotionEvent_getAction(ev);
174-
return (raw & MotionMask.PointerIndex) >> MotionMask.PointerIndexShift;
200+
return GetPointerIndex(raw);
175201
}
176-
}
202+
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
using System.Diagnostics;
2+
using StArray.ModManager.Manager;
3+
4+
namespace StArray.ModManager.Android.Native;
5+
6+
/// <summary>一次完整触摸事件的快照。所有字段在广播时即已从原生事件读出。</summary>
7+
/// <param name="Action">主动作,已去除指针索引</param>
8+
/// <param name="PointerIndex">指针索引</param>
9+
/// <param name="PointerId">稳定的指针 ID,比索引更适合跨事件跟踪多指输入</param>
10+
/// <param name="EventTimeNanos">事件发生时刻,纳秒,时钟源为 CLOCK_MONOTONIC</param>
11+
/// <param name="X">触点 X 坐标(像素)</param>
12+
/// <param name="Y">触点 Y 坐标(像素)</param>
13+
public readonly record struct TouchEventInfo(
14+
AndroidInput.MotionAction Action,
15+
int PointerIndex,
16+
int PointerId,
17+
long EventTimeNanos,
18+
float X,
19+
float Y)
20+
{
21+
// Source compatibility for mods built against the first broadcast API.
22+
public TouchEventInfo(
23+
AndroidInput.MotionAction action,
24+
int pointerIndex,
25+
long eventTimeNanos,
26+
float x,
27+
float y)
28+
: this(action, pointerIndex, -1, eventTimeNanos, x, y)
29+
{
30+
}
31+
}
32+
33+
/// <summary>
34+
/// 只包含异步输入所需字段的原始触摸快照。
35+
/// </summary>
36+
/// <remarks>
37+
/// 该事件不读取坐标,且只广播 Down/Up/Cancel。高 KPS 时订阅方不需要为 Move
38+
/// 事件读取坐标或创建完整手势快照。
39+
/// </remarks>
40+
public readonly record struct TouchTimestampInfo(
41+
AndroidInput.MotionAction Action,
42+
int PointerId,
43+
long EventTimeNanos);
44+
45+
/// <summary>
46+
/// 输入事件广播点。原生输入 Hook 只负责解析一次事件,订阅方在自己的队列中异步处理。
47+
/// </summary>
48+
/// <remarks>
49+
/// 回调运行在 Android 输入分发线程,不是 Unity 主线程。订阅方只能做廉价的值类型快照
50+
/// 和入队操作,不能访问 Unity 对象或执行 IL2CPP 游戏逻辑。
51+
/// </remarks>
52+
public static class InputEvents
53+
{
54+
private const string LogTag = nameof(InputEvents);
55+
private const long DuplicateWindowMilliseconds = 8L;
56+
57+
private static Action<TouchEventInfo>? s_onTouch;
58+
private static Action<TouchTimestampInfo>? s_onTouchTimestamp;
59+
private static int s_touchSubscriberCount;
60+
private static int s_touchTimestampSubscriberCount;
61+
private static int s_faultLogged;
62+
63+
private static readonly object DedupLock = new();
64+
private static int s_lastRawAction;
65+
private static int s_lastPointerIndex;
66+
private static int s_lastPointerCount;
67+
private static int s_lastPointerId;
68+
private static long s_lastEventTimeNanos;
69+
private static long s_lastDispatchTicks;
70+
71+
/// <summary>是否已有任一类订阅者。</summary>
72+
public static bool HasSubscribers =>
73+
Volatile.Read(ref s_touchSubscriberCount) > 0
74+
|| Volatile.Read(ref s_touchTimestampSubscriberCount) > 0;
75+
76+
/// <summary>完整触摸事件广播,保留坐标和 Move 事件。</summary>
77+
public static event Action<TouchEventInfo>? OnTouch
78+
{
79+
add
80+
{
81+
if (value == null) return;
82+
lock (DedupLock)
83+
{
84+
s_onTouch += value;
85+
Interlocked.Increment(ref s_touchSubscriberCount);
86+
}
87+
}
88+
remove
89+
{
90+
if (value == null) return;
91+
lock (DedupLock)
92+
{
93+
s_onTouch -= value;
94+
Interlocked.Decrement(ref s_touchSubscriberCount);
95+
}
96+
}
97+
}
98+
99+
/// <summary>
100+
/// 异步输入时间戳快速广播,只处理 Down、PointerDown、Up、PointerUp 和 Cancel。
101+
/// </summary>
102+
public static event Action<TouchTimestampInfo>? OnTouchTimestamp
103+
{
104+
add
105+
{
106+
if (value == null) return;
107+
lock (DedupLock)
108+
{
109+
s_onTouchTimestamp += value;
110+
Interlocked.Increment(ref s_touchTimestampSubscriberCount);
111+
}
112+
}
113+
remove
114+
{
115+
if (value == null) return;
116+
lock (DedupLock)
117+
{
118+
s_onTouchTimestamp -= value;
119+
Interlocked.Decrement(ref s_touchTimestampSubscriberCount);
120+
}
121+
}
122+
}
123+
124+
/// <summary>
125+
/// 从原生 AInputEvent 解析并广播。输入事件只在这里读取一次,避免每个 Mod 重复访问
126+
/// 原生对象;重复的同一事件只在广播层过滤一次。
127+
/// </summary>
128+
internal static void RaiseFrom(nint inputEvent)
129+
{
130+
Action<TouchEventInfo>? handlers;
131+
Action<TouchTimestampInfo>? timestampHandlers;
132+
lock (DedupLock)
133+
{
134+
handlers = s_onTouch;
135+
timestampHandlers = s_onTouchTimestamp;
136+
}
137+
138+
if ((handlers == null && timestampHandlers == null) || inputEvent == 0)
139+
return;
140+
141+
try
142+
{
143+
if (AndroidInput.AInputEvent_getType(inputEvent) != AndroidInput.EventType.Motion)
144+
return;
145+
146+
int rawAction = AndroidInput.AMotionEvent_getAction(inputEvent);
147+
AndroidInput.MotionAction action = AndroidInput.GetMainAction(rawAction);
148+
int pointerIndex = AndroidInput.GetPointerIndex(rawAction);
149+
bool timestampAction = action is AndroidInput.MotionAction.Down
150+
or AndroidInput.MotionAction.PointerDown
151+
or AndroidInput.MotionAction.Up
152+
or AndroidInput.MotionAction.PointerUp
153+
or AndroidInput.MotionAction.Cancel;
154+
155+
// AsyncInput subscribes only to the timestamp channel. Do not
156+
// inspect Move coordinates, pointer IDs, or pointer counts on the
157+
// hot path when no full-gesture subscriber exists.
158+
if (!timestampAction && handlers == null)
159+
return;
160+
161+
int pointerCount = AndroidInput.AMotionEvent_getPointerCount(inputEvent);
162+
long eventTimeNanos = AndroidInput.AMotionEvent_getEventTime(inputEvent);
163+
int pointerId = action == AndroidInput.MotionAction.Cancel
164+
? -1
165+
: AndroidInput.AMotionEvent_getPointerId(inputEvent, pointerIndex);
166+
167+
if (IsDuplicate(
168+
rawAction,
169+
pointerIndex,
170+
pointerCount,
171+
pointerId,
172+
eventTimeNanos))
173+
return;
174+
175+
if (timestampHandlers != null && timestampAction)
176+
{
177+
DispatchTimestampHandlers(
178+
timestampHandlers,
179+
new TouchTimestampInfo(action, pointerId, eventTimeNanos));
180+
}
181+
182+
if (handlers == null)
183+
return;
184+
185+
TouchEventInfo info = new(
186+
action,
187+
pointerIndex,
188+
pointerId,
189+
eventTimeNanos,
190+
AndroidInput.AMotionEvent_getX(inputEvent, pointerIndex),
191+
AndroidInput.AMotionEvent_getY(inputEvent, pointerIndex));
192+
DispatchTouchHandlers(handlers, info);
193+
}
194+
catch (Exception exception)
195+
{
196+
LogOnce($"Failed to read native input event: {exception}");
197+
}
198+
}
199+
200+
private static bool IsDuplicate(
201+
int rawAction,
202+
int pointerIndex,
203+
int pointerCount,
204+
int pointerId,
205+
long eventTimeNanos)
206+
{
207+
long now = Stopwatch.GetTimestamp();
208+
long windowTicks = Math.Max(
209+
1L,
210+
Stopwatch.Frequency * DuplicateWindowMilliseconds / 1000L);
211+
212+
lock (DedupLock)
213+
{
214+
long elapsed = now - s_lastDispatchTicks;
215+
bool sameEventPayload = s_lastRawAction == rawAction
216+
&& s_lastPointerIndex == pointerIndex
217+
&& s_lastPointerCount == pointerCount
218+
&& s_lastPointerId == pointerId
219+
&& s_lastEventTimeNanos == eventTimeNanos;
220+
bool duplicate = sameEventPayload
221+
&& elapsed >= 0L
222+
&& elapsed <= windowTicks;
223+
224+
if (duplicate)
225+
return true;
226+
227+
s_lastRawAction = rawAction;
228+
s_lastPointerIndex = pointerIndex;
229+
s_lastPointerCount = pointerCount;
230+
s_lastPointerId = pointerId;
231+
s_lastEventTimeNanos = eventTimeNanos;
232+
s_lastDispatchTicks = now;
233+
return false;
234+
}
235+
}
236+
237+
private static void DispatchTimestampHandlers(
238+
Action<TouchTimestampInfo> handlers,
239+
TouchTimestampInfo info)
240+
{
241+
if (Volatile.Read(ref s_touchTimestampSubscriberCount) == 1)
242+
{
243+
try
244+
{
245+
handlers(info);
246+
}
247+
catch (Exception exception)
248+
{
249+
LogOnce($"Touch timestamp subscriber threw: {exception}");
250+
}
251+
return;
252+
}
253+
254+
foreach (Delegate handler in handlers.GetInvocationList())
255+
{
256+
try
257+
{
258+
((Action<TouchTimestampInfo>)handler)(info);
259+
}
260+
catch (Exception exception)
261+
{
262+
LogOnce($"Touch timestamp subscriber threw: {exception}");
263+
}
264+
}
265+
}
266+
267+
private static void DispatchTouchHandlers(
268+
Action<TouchEventInfo> handlers,
269+
TouchEventInfo info)
270+
{
271+
if (Volatile.Read(ref s_touchSubscriberCount) == 1)
272+
{
273+
try
274+
{
275+
handlers(info);
276+
}
277+
catch (Exception exception)
278+
{
279+
LogOnce($"Touch event subscriber threw: {exception}");
280+
}
281+
return;
282+
}
283+
284+
foreach (Delegate handler in handlers.GetInvocationList())
285+
{
286+
try
287+
{
288+
((Action<TouchEventInfo>)handler)(info);
289+
}
290+
catch (Exception exception)
291+
{
292+
LogOnce($"Touch event subscriber threw: {exception}");
293+
}
294+
}
295+
}
296+
297+
private static void LogOnce(string message)
298+
{
299+
if (Interlocked.Exchange(ref s_faultLogged, 1) != 0)
300+
return;
301+
try
302+
{
303+
Logger.Error(LogTag, $"{message} (further occurrences suppressed)");
304+
}
305+
catch
306+
{
307+
// Never allow an exception to escape back through the native input stack.
308+
}
309+
}
310+
}

0 commit comments

Comments
 (0)