-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBhMenuRowClick.java
More file actions
507 lines (472 loc) · 23.8 KB
/
Copy pathBhMenuRowClick.java
File metadata and controls
507 lines (472 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
package com.xj.winemu.vibration;
import android.app.Activity;
import android.content.Intent;
import android.util.Log;
import com.xj.winemu.common.BhMenuGameId;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Onclick handler for the "PC Vibration Settings" row injected into the
* three per-game library menu surfaces (game detail More Menu, library-tile
* popup, library-list 3-dot popup).
*
* The host APK ships kotlin-stdlib so all Function0/Function1 / Unit
* references work at runtime. We deliberately don't `implements Function1`
* here so this fork's javac step doesn't need kotlin-stdlib on the
* classpath — the host's R8 rename means a direct Java `implements
* Function1` wouldn't satisfy the host's type check anyway. All Function0
* / Function1 contracts are fulfilled by {@link java.lang.reflect.Proxy}
* instances created at row-construction time inside the appendXxxRow
* helpers below.
*
* The Context is resolved at click time by reflectively walking
* ActivityThread.mActivities to find the currently-resumed Activity. This
* avoids needing a captured Context at construction time, which would
* otherwise require the bytecode patch to find an appropriate Context
* register inside the heavily-obfuscated Compose Composables.
*
* The per-game id is read from {@link BhMenuGameId#getCaptured()} (set by
* the index-0 captureGameId(p0) injection in each of the three menu
* builders), so the dialog opens scoped to the right game even from a
* pre-launch menu where no WineActivity is on the stack yet.
*/
public final class BhMenuRowClick {
private static final String TAG = "BhMenuRowClick";
/** Cached kotlin.Unit.INSTANCE resolved via reflection — runtime-only
* so this Java module compiles without kotlin-stdlib on the classpath. */
private static volatile Object UNIT;
private static Object kotlinUnit() {
Object u = UNIT;
if (u != null) return u;
try {
try {
Class<?> c = Class.forName("kotlin.Unit");
u = c.getField("INSTANCE").get(null);
} catch (Throwable keptNameMissing) {
// 6.0.9: R8 obfuscated kotlin.Unit -> Lx6m; (INSTANCE = a).
Class<?> c = Class.forName("x6m");
u = c.getDeclaredField("a").get(null);
}
UNIT = u;
return u;
} catch (Throwable t) {
return null;
}
}
public Object invoke(Object ignoredFromCompose) {
try {
Activity host = resolveTopActivity();
if (host == null) {
Log.w(TAG, "no top Activity resolvable; cannot launch settings");
return kotlinUnit();
}
Intent intent = new Intent(host, BhVibrationSettingsActivity.class);
String gameId = BhMenuGameId.getCaptured();
if (gameId == null || gameId.isEmpty()) gameId = sniffGameIdFromStack();
if (gameId != null && !gameId.isEmpty()) {
// BhVibrationSettingsActivity reads EXTRA_GAME_ID
// ("bh_vibration.gameId"), not "gameId" — using the
// wrong key here is why per-game never took effect.
intent.putExtra(BhVibrationSettingsActivity.EXTRA_GAME_ID, gameId);
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
host.startActivity(intent);
} catch (Throwable t) {
Log.w(TAG, "menu click failed", t);
}
return kotlinUnit();
}
/** Walk ActivityThread.mActivities to find the most-recently-resumed Activity. */
private static Activity resolveTopActivity() {
try {
Class<?> atCls = Class.forName("android.app.ActivityThread");
Method cur = atCls.getMethod("currentActivityThread");
Object at = cur.invoke(null);
if (at == null) return null;
Field fActs = atCls.getDeclaredField("mActivities");
fActs.setAccessible(true);
Object acts = fActs.get(at);
if (!(acts instanceof Map)) return null;
Activity best = null;
for (Object record : ((Map<?, ?>) acts).values()) {
if (record == null) continue;
Field fAct = record.getClass().getDeclaredField("activity");
fAct.setAccessible(true);
Object a = fAct.get(record);
if (!(a instanceof Activity)) continue;
Activity activity = (Activity) a;
if (activity.isFinishing()) continue;
try {
Field fPaused = record.getClass().getDeclaredField("paused");
fPaused.setAccessible(true);
Object paused = fPaused.get(record);
if (paused instanceof Boolean && !((Boolean) paused)) {
return activity;
}
} catch (NoSuchFieldException ignored) { }
best = activity;
}
return best;
} catch (Throwable t) {
Log.w(TAG, "resolveTopActivity failed", t);
return null;
}
}
/**
* Game detail More Menu row appender. Constructs a Luhd; 3-arg row
* (6.0.4 Liae;) via reflection and adds it to the passed list builder.
* Called from a single-instruction smali injection inside Llc7.a (6.0.4
* Lx57.a) — keeps the bytecode patch trivial (no register juggling, no
* verifier risk) at the cost of a runtime reflection lookup.
*
* The obfuscated class names uhd/qd5/t47/yc5 are the GameHub 6.0.9
* letters (6.0.4: iae/o05/pw6/zz4); if a future R8 map shifts them the
* helper silently no-ops (logged) and the menu falls back to the
* original rows.
*/
public static void appendVibrationRowTo(Object menuList) {
try {
if (!(menuList instanceof List)) return;
@SuppressWarnings("unchecked")
List<Object> list = (List<Object>) menuList;
Class<?> iaeCls = Class.forName("uhd");
Class<?> o05Cls = Class.forName("qd5");
Class<?> pw6Cls = Class.forName("t47");
// Resolve a menu-row icon. Lyc5 (6.0.4 Lzz4) is the
// ComposableSingletons class for menu-row icons; field `x` holds
// an Lu3k (6.0.4 Lxrl) Lazy whose getValue() returns an Lqd5
// (6.0.4 Lo05). We MUST use a field the host's own menu builders
// already render: Llc7;->a (this More Menu) and Lqqc;->f (tile
// popup) both use Lyc5;->x. The earlier choice Lyc5;->b0 is an
// icon for a DIFFERENT surface and crashed Compose when rendered
// here (NoClassDefFound/render fault in do8). x is verified-safe.
Class<?> zz4Cls = Class.forName("yc5");
Field iconHolderField = zz4Cls.getDeclaredField("x");
iconHolderField.setAccessible(true);
Object xrlWrapper = iconHolderField.get(null);
if (xrlWrapper == null) {
Log.w(TAG, "yc5.x is null; cannot resolve icon");
return;
}
Object iconValue = xrlWrapper.getClass().getMethod("getValue").invoke(xrlWrapper);
if (!o05Cls.isInstance(iconValue)) {
Log.w(TAG, "yc5.x.getValue() did not return Lqd5");
return;
}
// R8 renamed kotlin.jvm.functions.Function1 to Lt47 (6.0.4
// Lpw6) in the host APK, so our Java `implements
// Function1<Object, Object>` IS a different JVM class from the
// host's Lt47. Luhd's ctor requires Lt47 specifically — direct
// Java implements doesn't satisfy the type check. Fix: a Proxy
// that implements Lgv6 at runtime and delegates invoke to
// our BhMenuRowClick.
final BhMenuRowClick handler = new BhMenuRowClick();
Object click = java.lang.reflect.Proxy.newProxyInstance(
pw6Cls.getClassLoader(),
new Class<?>[]{ pw6Cls },
(proxy, method, args) -> {
if ("invoke".equals(method.getName()) && method.getParameterCount() == 1) {
return handler.invoke(args != null && args.length > 0 ? args[0] : null);
}
if ("equals".equals(method.getName())) return proxy == args[0];
if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy);
if ("toString".equals(method.getName())) return "BhMenuRowClickProxy";
return null;
}
);
Constructor<?> ctor =
iaeCls.getDeclaredConstructor(o05Cls, String.class, pw6Cls);
ctor.setAccessible(true);
Object row = ctor.newInstance(iconValue, "PC Vibration Settings", click);
list.add(row);
} catch (Throwable t) {
Log.w(TAG, "appendVibrationRowTo failed", t);
}
}
/**
* Library-tile popup variant (6.0.9 Lqqc.f, 6.0.4 Lted.f). Rows use
* Lxoc(String actionId, Lqd5 icon, String label, Lr47 onClick) (6.0.4
* Lscd / Lo05 / Lnw6) with a Function0 click handler (no args), and the
* rows are collected into an ArrayList via the host's arrayListOf helper
* (6.0.9 Llp0;->R, 6.0.4 Lqs2;->H).
*
* The smali injection replaces that list with a new ArrayList containing
* the original rows plus our PC Vibration Settings row. Returns an
* ArrayList (NOT a bare List): in 6.0.9 the host threads the result
* register through ArrayList.size()/get(I), so the return type must be
* Ljava/util/ArrayList; or dex verification fails. The smali captures the
* return value and reassigns it to the list register.
*/
public static ArrayList<Object> appendScdRowToTedList(Object original) {
try {
if (!(original instanceof List)) return safeReturnArrayList(original);
List<?> origList = (List<?>) original;
ArrayList<Object> augmented = new ArrayList<>(origList);
Class<?> scdCls = Class.forName("xoc");
Class<?> o05Cls = Class.forName("qd5");
Class<?> nw6Cls = Class.forName("r47");
Class<?> zz4Cls = Class.forName("yc5");
Field iconField = zz4Cls.getDeclaredField("x");
iconField.setAccessible(true);
Object xrlWrapper = iconField.get(null);
if (xrlWrapper == null) return safeReturnArrayList(original);
Object iconValue = xrlWrapper.getClass().getMethod("getValue").invoke(xrlWrapper);
if (!o05Cls.isInstance(iconValue)) return safeReturnArrayList(original);
// Function0 onClick via Proxy implementing Lnw6.
final BhMenuRowClick handler = new BhMenuRowClick();
Object click = java.lang.reflect.Proxy.newProxyInstance(
nw6Cls.getClassLoader(),
new Class<?>[]{ nw6Cls },
(proxy, method, args) -> {
if ("invoke".equals(method.getName()) && method.getParameterCount() == 0) {
return handler.invoke(null);
}
if ("equals".equals(method.getName())) return proxy == args[0];
if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy);
if ("toString".equals(method.getName())) return "BhMenuRowClickProxy0";
return null;
}
);
Constructor<?> ctor =
scdCls.getDeclaredConstructor(String.class, o05Cls, String.class, nw6Cls);
ctor.setAccessible(true);
Object row = ctor.newInstance(
"local_detail_menu_pc_vibration",
iconValue,
"PC Vibration Settings",
click
);
augmented.add(row);
return augmented;
} catch (Throwable t) {
Log.w(TAG, "appendScdRowToTedList failed", t);
return safeReturnArrayList(original);
}
}
/**
* Library-list 3-dot popup variant (6.0.9 Lxdc.b0, 6.0.4 Lpzc.j0). Uses
* a third row data class:
* Lpcd(Llok label, Lr47 onClick, int) [synthetic 3-arg ctor]
* (6.0.4: Lz4e(Lell, Lnw6, int))
* - Llok extends Lo4h(String key, Set<String> locales) (6.0.4
* Lell extends Ltdi), a Compose Multiplatform string-resource
* descriptor; resolved at render time by Ly99.c0 (6.0.4 Lxd3.l1).
* - Lr47 is Function0 (no-arg lambda) (6.0.4 Lnw6).
*
* Our label key "bh_pc_vibration_label" is also patched into the
* resolver Ly99.c0 via maybeResolveCustomLabel below, so the
* Compose runtime doesn't need a matching CVR entry to render
* "PC Vibration Settings".
*/
public static List<Object> appendLibraryPopupRow(Object original) {
try {
if (!(original instanceof List)) return safeReturn(original);
List<?> origList = (List<?>) original;
ArrayList<Object> augmented = new ArrayList<>(origList);
Class<?> z4eCls = Class.forName("pcd");
Class<?> ellCls = Class.forName("lok");
Class<?> tdiCls = Class.forName("o4h");
Class<?> nw6Cls = Class.forName("r47");
// Ldwj (6.0.4 Lell) is a Kotlin empty subclass of abstract
// Lo4h(String, Set<String>) (6.0.4 Ltdi) — at bytecode level the
// host does `new-instance Llok; invoke Lo4h.<init>`, but
// ellCls.getDeclaredConstructor(String.class, Set.class)
// returns nothing because Llok declares no ctor itself.
// Workaround: allocate Llok via sun.misc.Unsafe (skips
// ctor entirely) and reflect-set the inherited Lo4h
// fields a (key) and b (locales).
Class<?> unsafeCls = Class.forName("sun.misc.Unsafe");
Field theUnsafe = unsafeCls.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Object unsafe = theUnsafe.get(null);
Object label = unsafeCls.getMethod("allocateInstance", Class.class)
.invoke(unsafe, ellCls);
Field aField = tdiCls.getDeclaredField("a");
aField.setAccessible(true);
aField.set(label, "string:bh_pc_vibration_label");
Field bField = tdiCls.getDeclaredField("b");
bField.setAccessible(true);
bField.set(label, Collections.emptySet());
final BhMenuRowClick handler = new BhMenuRowClick();
Object click = java.lang.reflect.Proxy.newProxyInstance(
nw6Cls.getClassLoader(),
new Class<?>[]{ nw6Cls },
(proxy, method, args) -> {
if ("invoke".equals(method.getName()) && method.getParameterCount() == 0) {
return handler.invoke(null);
}
if ("equals".equals(method.getName())) return proxy == args[0];
if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy);
if ("toString".equals(method.getName())) return "BhLibPopupRowClick";
return null;
}
);
// Lpcd(Llok;Lr47;I)V synthetic ctor (6.0.4 Lz4e(Lell;Lnw6;I)V)
// — int=0 should be a safe default group/category marker.
Constructor<?> z4eCtor =
z4eCls.getDeclaredConstructor(ellCls, nw6Cls, int.class);
z4eCtor.setAccessible(true);
Object row = z4eCtor.newInstance(label, click, 0);
augmented.add(row);
return augmented;
} catch (Throwable t) {
Log.w(TAG, "appendLibraryPopupRow failed", t);
return safeReturn(original);
}
}
@SuppressWarnings("unchecked")
private static List<Object> safeReturn(Object o) {
if (o instanceof List) return (List<Object>) o;
return new ArrayList<>();
}
/** ArrayList-typed fallback for appendScdRowToTedList — its 6.0.9 caller
* (Lqqc.f) consumes the result via ArrayList.size()/get(I), so the
* return must be a concrete ArrayList, never a bare List. */
@SuppressWarnings("unchecked")
private static ArrayList<Object> safeReturnArrayList(Object o) {
if (o instanceof ArrayList) return (ArrayList<Object>) o;
if (o instanceof java.util.Collection) {
return new ArrayList<>((java.util.Collection<Object>) o);
}
return new ArrayList<>();
}
/**
* Patched into the resolver Ly99.c0 (6.0.4 Lxd3.l1) to short-circuit our
* sentinel key BEFORE it hits the Compose Multiplatform resource lookup
* (which throws "Resource with ID='string:bh_pc_vibration_label'
* not found" because the runtime expects a manifest registration
* alongside the .cvr entry, and just appending to the .cvr isn't
* enough).
*
* Returns the row label when our sentinel key matches; returns
* null otherwise so the stock resolver path runs unchanged.
*/
public static String maybeResolveCustomLabel(Object ell) {
return resolveCustomLabel(ell, true);
}
/**
* Same as {@link #maybeResolveCustomLabel} but with the
* kickImportFromDialogOpen side effect suppressed. Used by the
* non-Compose / suspend resolver hooks (Ly99;->d0/J/K, 6.0.4
* Lxd3;->m1/P0/Q0): those paths
* exist to surface resource strings outside composition (e.g. toast
* format strings), so we want our label overrides to apply but we
* absolutely do not want a stray non-Compose lookup of the import-
* dialog-title key to launch a SAF file picker behind the user's back.
*/
public static String maybeResolveCustomLabelNoKick(Object ell) {
return resolveCustomLabel(ell, false);
}
private static String resolveCustomLabel(Object ell, boolean fireSideEffects) {
try {
Field aField = Class.forName("o4h").getDeclaredField("a");
aField.setAccessible(true);
Object key = aField.get(ell);
if (key == null) return null;
String label = null;
if ("string:bh_pc_vibration_label".equals(key)) {
label = "PC Vibration Settings";
} else if ("string:bh_vjoy_export_label".equals(key)) {
label = "Export to file";
} else if ("string:bh_vjoy_import_label".equals(key)) {
label = "Import from file";
}
// VJoy share-flow stock-string OVERRIDES (not new entries; we
// hijack the host's own keys before its CVR lookup runs). The
// share button now exports to a local .gtheme file, so relabel
// the user-visible strings to match.
//
// 6.0.7: the VJoy share/import strings moved from the
// `features_vjoy_*` namespace to the `common.vjoy` bundle with
// `common_vjoy_layout_*` keys (re-verified from the 6.0.9
// CVR). The keys below are the 6.0.9 names; the resource-id
// format is still "string:" + key (proven by the menu-row label
// working). Falls through to the stock label on any non-match.
else if ("string:common_vjoy_layout_func_share".equals(key)) {
label = "Export"; // was "Share"
} else if ("string:common_vjoy_layout_dialog_prepare_share_title".equals(key)) {
label = "Name Profile"; // was "Publish to Cloud"
} else if ("string:common_vjoy_layout_dialog_prepare_share_placeholder".equals(key)) {
label = "Profile name"; // was "Share name"
}
// NOTE: the post-export "Cloud Backup Code" dialog
// (features_vjoy_dialog_share_code_*) is no longer relabeled or
// dismissed here — BhVjoyShareHook.interceptShare (the shareMap
// hook) THROWS to abort the cloud publish before that dialog is
// ever composed, so those resource keys never resolve.
// Import: relabel the entry point and use the import-dialog title
// resolution as the composition-time signal to fire the SAF file
// picker and skip the share-code dialog entirely.
else if ("string:features_vjoy_main_action_import".equals(key)) {
label = "Import Layout from File"; // was "Import Layout"
} else if ("string:common_vjoy_layout_dialog_import_share_code_title".equals(key)) {
label = "Import Layout";
// This resource ONLY resolves when the import dialog is being
// composed, so use it as the "dialog opening" signal and fire
// SAF immediately. SAF takes focus over the briefly-composed
// dialog; after the user picks/cancels we dismiss the leftover
// dialog via a programmatic BACK. IMPORT_IN_FLIGHT (in the hook)
// gates against the dozens of recompositions per dialog show.
if (fireSideEffects) {
try {
com.xj.winemu.exportcontrols.BhVjoyShareHook.kickImportFromDialogOpen();
} catch (Throwable t) {
Log.w(TAG, "kickImportFromDialogOpen threw", t);
}
}
} else if ("string:common_vjoy_layout_dialog_import_share_code_placeholder".equals(key)) {
label = "Opening file picker…";
}
// Suppress the "Operation failed, please try again." toast that
// fires after interceptShare throws to abort the cloud publish.
// 6.0.9 dropped the share-specific failure string and shows this
// generic key instead; overriding to "" makes Android skip the
// (now empty) toast. NOTE: this key is generic to layout ops, so
// genuine non-share "operation failed" toasts are also swallowed
// — acceptable trade for a clean local-export UX, but revisit if
// it hides a real error elsewhere.
else if ("string:common_vjoy_layout_toast_operation_failed".equals(key)) {
label = "";
}
if (label != null) {
Log.i(TAG, "maybeResolveCustomLabel key=" + key + " → '" + label + "'");
return label;
}
} catch (Throwable t) {
Log.w(TAG, "maybeResolveCustomLabel error", t);
}
return null;
}
/** If a WineActivity is in the stack, grab its gameId Intent extra. */
private static String sniffGameIdFromStack() {
try {
Class<?> atCls = Class.forName("android.app.ActivityThread");
Method cur = atCls.getMethod("currentActivityThread");
Object at = cur.invoke(null);
if (at == null) return null;
Field fActs = atCls.getDeclaredField("mActivities");
fActs.setAccessible(true);
Object acts = fActs.get(at);
if (!(acts instanceof Map)) return null;
for (Object record : ((Map<?, ?>) acts).values()) {
if (record == null) continue;
Field fAct = record.getClass().getDeclaredField("activity");
fAct.setAccessible(true);
Object a = fAct.get(record);
if (!(a instanceof Activity)) continue;
String clsName = a.getClass().getName();
if (!clsName.endsWith(".WineActivity")) continue;
Intent it = ((Activity) a).getIntent();
if (it == null) continue;
String gid = it.getStringExtra("gameId");
if (gid != null && !gid.isEmpty()) return gid;
}
} catch (Throwable ignored) { }
return null;
}
}