package touch.inject; import android.app.Service; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.hardware.input.InputManager; import android.os.IBinder; import android.os.SystemClock; import android.util.Log; import android.view.MotionEvent; import java.util.LinkedHashMap; import java.util.Map; /** * Multi-touch gesture injector for the Pad emulator. * * Runs as a privileged system app (/system/priv-app) so that the * signature|privileged INJECT_EVENTS permission is granted; it then injects * real MotionEvents through InputManager — the exact path a physical touch * screen takes — which is what the adb `input` applet cannot do (it has no * multi-touch support). * * Usage: * adb shell am startservice -n touch.inject/.Injector -e script "SCRIPT" * * SCRIPT is a whitespace-separated sequence of: * down finger (1-based) touches at (x,y) px * move finger moves to (x,y) px * up finger lifts * wait pause * * Progress is logged to logcat under the "TouchInject" tag. */ public class Injector extends Service { public static final String TAG = "TouchInject"; /** Shell-triggerable entry point: works even while the app is stopped * (the shell can broadcast to an explicit component). * KNOWN FLAKE: the process is "cached" while the thread runs and the * 1.5GB emulator OOM-killed it once mid-script (during a 200ms wait), * losing the final UP. Service routing is NOT an alternative — Android * 12+ blocks background startService from a receiver, and the AVD's * locked bootloader blocks the system-app escalation. The harness * therefore verifies "=== done" in logcat after every script and * re-runs on failure; scripts should stay short. */ public static class ScriptReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String script = intent.getStringExtra("script"); if (script == null) { return; } InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); new Thread(() -> execute(im, script), "TouchInject").start(); } } @Override public int onStartCommand(Intent intent, int flags, int startId) { String script = intent != null ? intent.getStringExtra("script") : null; if (script == null) { stopSelf(); return START_NOT_STICKY; } InputManager im = (InputManager) getSystemService(INPUT_SERVICE); new Thread(() -> execute(im, script), "TouchInject").start(); return START_NOT_STICKY; } @Override public IBinder onBind(Intent intent) { return null; } private static void execute(InputManager im, String script) { Log.i(TAG, "=== script: " + script); // finger number (1-based) -> [x, y]; insertion order = pointer index. LinkedHashMap fingers = new LinkedHashMap<>(); long downTime = 0; String[] toks = script.split("\\s+"); int i = 0; while (i < toks.length) { String cmd = toks[i++]; try { switch (cmd) { case "down": { int f = Integer.parseInt(toks[i++]); float x = Float.parseFloat(toks[i++]); float y = Float.parseFloat(toks[i++]); int action = fingers.isEmpty() ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_POINTER_DOWN; int idx = fingers.size(); fingers.put(f, new float[]{x, y}); inject(im, action, idx, fingers, downTime); if (downTime == 0) { downTime = SystemClock.uptimeMillis(); } break; } case "move": { int f = Integer.parseInt(toks[i++]); float x = Float.parseFloat(toks[i++]); float y = Float.parseFloat(toks[i++]); float[] p = fingers.get(f); if (p == null) { Log.w(TAG, "move of unknown finger " + f + " (skipped)"); break; } p[0] = x; p[1] = y; // Generic ACTION_MOVE for all moves (1 or N pointers): // InputFlinger accepts it and GioView treats every // pointer as a MOVE. inject(im, MotionEvent.ACTION_MOVE, 0, fingers, downTime); break; } case "up": { int f = Integer.parseInt(toks[i++]); if (!fingers.containsKey(f)) { Log.w(TAG, "up of unknown finger " + f + " (skipped)"); break; } boolean last = fingers.size() == 1; int action = last ? MotionEvent.ACTION_UP : MotionEvent.ACTION_POINTER_UP; int idx = indexOf(fingers, f); // The lifted pointer must still be part of the event. inject(im, action, idx, fingers, downTime); fingers.remove(f); if (fingers.isEmpty()) { downTime = 0; } break; } case "wait": { int ms = Integer.parseInt(toks[i++]); Thread.sleep(ms); break; } default: Log.e(TAG, "unknown command: " + cmd); } } catch (Exception e) { Log.e(TAG, "script error at '" + cmd + "': " + e); break; } } Log.i(TAG, "=== done"); } private static int indexOf(LinkedHashMap m, int f) { int idx = 0; for (Integer k : m.keySet()) { if (k == f) { return idx; } idx++; } return 0; } // INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT. The SDK stub jar omits the // constant (and injectInputEvent itself), so both go through reflection; // the method is a public API at runtime on the device. private static final int INJECT_WAIT_FOR_RESULT = 1; private static void inject(InputManager im, int action, int actionIndex, Map fingers, long downTime) { int n = fingers.size(); int[] ids = new int[n]; MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[n]; int j = 0; for (Map.Entry e : fingers.entrySet()) { ids[j] = e.getKey() - 1; // pointer id (0-based) MotionEvent.PointerCoords c = new MotionEvent.PointerCoords(); c.x = e.getValue()[0]; c.y = e.getValue()[1]; c.pressure = 1f; c.size = 1f; // TOOL_TYPE_FINGER. The compile-time android.jar lacks // PointerCoords.setToolType, but the device runtime (API 24+) // has it, so call it reflectively. try { java.lang.reflect.Method stt = MotionEvent.PointerCoords.class .getMethod("setToolType", int.class); stt.invoke(c, 1); } catch (Exception ignored) { } coords[j] = c; j++; } long now = SystemClock.uptimeMillis(); int fullAction = action == MotionEvent.ACTION_MOVE ? action : action | (actionIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); MotionEvent ev = MotionEvent.obtain(downTime, now, fullAction, n, ids, coords, 0 /*edgeFlags*/, 1f /*xPrecision*/, 1f /*yPrecision*/, 0 /*metaState*/, 0 /*deviceId*/, 0x4000003 /*SOURCE_TOUCH|SOURCE_CLASS_MASK*/, 0 /*displayId*/); boolean ok = doInject(im, ev); Log.i(TAG, String.format("inject action=%d idx=%d n=%d ids=%s ok=%b", action, actionIndex, n, java.util.Arrays.toString(ids), ok)); ev.recycle(); } private static boolean doInject(InputManager im, MotionEvent ev) { try { java.lang.reflect.Method m = InputManager.class.getMethod( "injectInputEvent", android.view.InputEvent.class, int.class); Object r = m.invoke(im, ev, INJECT_WAIT_FOR_RESULT); return r instanceof Boolean && (Boolean) r; } catch (Exception e) { Throwable c = e.getCause() != null ? e.getCause() : e; Log.e(TAG, "injectInputEvent failed: " + c); return false; } } }