Last active
May 26, 2022 15:19
-
-
Save stoefln/ede709c0ec07d158d8533229f22705ae to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//@ts-check | |
const exec = require('child_process').exec | |
import Shell from './Shell' | |
import DeviceData from '../models/DeviceData' | |
import sleep from '../utils/sleep' | |
import log from 'loglevel' | |
import { sendEvent } from './Analytics' | |
import { MissingServiceError } from './Errors' | |
function MiniTouchError(message) { | |
this.message = message | |
} | |
MiniTouchError.prototype = new Error() | |
const StfAgentName = 'stf.agent' | |
const StfServiceName = 'jp.co.cyberagent.stf' | |
const MiniTouchProcessName = 'minitouch' | |
export { MiniTouchError } | |
/** | |
* This class installs the MiniTouch app on the testing device, so we can send touch commands there | |
*/ | |
export default class MiniTouch { | |
constructor(port, onError, logLine) { | |
this.port = port | |
this.onError = onError | |
this.logLine = logLine | |
// used as a workaround when device mapping is not working | |
this.stfAgentPort = 1090 | |
} | |
addLineToConnectionLog = line => { | |
this.logLine('MiniTouch: ' + line) | |
} | |
groupCollapsed = title => { | |
console.group(title) | |
this.addLineToConnectionLog('Start: ' + title.toUpperCase()) | |
} | |
/** | |
* | |
* @param {String} deviceId | |
* @param {typeof DeviceData.Type} data | |
*/ | |
async connect(deviceId, data) { | |
if (this.deviceId) { | |
log.warn(`Device with ${this.deviceId} is still connected! Please disconnect first!`) | |
} | |
if (this.childProcess) { | |
console.error(`A child process is still running. Disconnect first!`) | |
} | |
this.deviceId = deviceId | |
this.deviceData = data | |
this.connectionRetries = 0 | |
this.useEmulatorHack = this.deviceData?.isEmulator | |
this.groupCollapsed('MiniTouch.connect()') | |
if (parseInt(data.sdk) >= 29) { | |
// we need STFService here | |
this.addLineToConnectionLog(`Android 10 or above (API level ${parseInt(data.sdk)}): Installing STFService...`) | |
var dir = '/data/local/tmp/stfservice' | |
// https://github.com/openstf/STFService.apk/ | |
var bin = 'stfservice2.apk' | |
var fileExistsResult | |
var installNeeded = false | |
try { | |
fileExistsResult = await Shell.execADBCommand(['shell', `ls ${dir + '/' + bin}`], this.deviceId, this.addLineToConnectionLog) | |
this.addLineToConnectionLog(`fileExistsResult:${fileExistsResult}` + fileExistsResult) | |
} catch (e) { | |
this.addLineToConnectionLog('File not found...') | |
} | |
if (fileExistsResult && fileExistsResult.indexOf('No such file') == -1) { | |
this.addLineToConnectionLog('STFService file found on device, no need to copy') | |
} else { | |
this.addLineToConnectionLog('Pushing ' + bin) | |
// Clean up | |
try { | |
await Shell.execADBCommand(['shell', 'rm', '-r', dir], this.deviceId, this.addLineToConnectionLog) | |
} catch (e) {} | |
await Shell.execADBCommand(['shell', 'mkdir', dir], this.deviceId, this.addLineToConnectionLog) | |
// Upload the binary | |
await Shell.adbPushFile('./apk/' + bin, dir, this.deviceId, this.addLineToConnectionLog) | |
this.addLineToConnectionLog(bin + ' pushed') | |
installNeeded = true | |
} | |
// if we just copied the apk, we also want to reinstall | |
// else (the file was not copied), we still want to know if the apk was installed | |
// if it was not installed, we install it | |
if (!installNeeded) { | |
// Check if installed already | |
const installedStr = await Shell.execADBCommand( | |
['shell', 'pm', 'list', 'packages', StfServiceName], | |
this.deviceId, | |
this.addLineToConnectionLog | |
) | |
if (installedStr.indexOf(StfServiceName) == -1) { | |
// not installed | |
installNeeded = true | |
} | |
} | |
if (installNeeded) { | |
await Shell.execADBCommand(['shell', 'pm', 'install', '-t', '-r', '-d', dir + '/' + bin], this.deviceId, this.addLineToConnectionLog) | |
this.addLineToConnectionLog('STFService installed') | |
} | |
this.addLineToConnectionLog('Starting STFService...') | |
await Shell.execADBCommand( | |
['shell', 'am', 'start-foreground-service', '--user', '0', '-a', 'jp.co.cyberagent.stf.ACTION_START', '-n', 'jp.co.cyberagent.stf/.Service'], | |
this.deviceId, | |
this.addLineToConnectionLog | |
) | |
this.addLineToConnectionLog('Forwarding service on port ' + this.port) | |
await Shell.execADBCommand(['forward', 'tcp:' + this.port, 'localabstract:stfservice'], this.deviceId, this.addLineToConnectionLog) | |
const lines = await Shell.execADBCommand(['shell', 'pm', 'path', StfServiceName], this.deviceId, this.addLineToConnectionLog) | |
this.addLineToConnectionLog('path lines: ' + lines) | |
if (lines.indexOf(`Can't find service`) > -1) { | |
throw new MissingServiceError(lines) | |
} | |
const linesArr = lines.split('\n') | |
const filteredLines = linesArr.filter(line => line.indexOf('jp.co.cyberagent') != -1) | |
// eg: "package:/data/app/jp.co.cyberagent.stf-8rKYplon9JfeF32y2zwBjA==/base.apk" | |
if (filteredLines.length == 0) { | |
throw new Error('Error while trying to read service apk path') | |
} | |
const apkPath = filteredLines[0] | |
.replace('package:', '') | |
.replace('\r', '') | |
.trim() | |
this.addLineToConnectionLog('apkPath: ' + apkPath) | |
this.addLineToConnectionLog('exec app_process STFService Agent...') | |
this.stfServiceAgent = Shell.open( | |
Shell.getAdbPath(), | |
['-s', this.deviceId, 'shell', 'export', `CLASSPATH=${apkPath}\;`, 'exec', 'app_process', '/system/bin', 'jp.co.cyberagent.stf.Agent'], | |
'StfServiceAgent' | |
) | |
this.addLineToConnectionLog('AFTER exec app_process STFService Agent') | |
await Shell.execADBCommand(['forward', 'tcp:' + this.stfAgentPort, 'localabstract:stfagent'], this.deviceId, this.addLineToConnectionLog) | |
this.addLineToConnectionLog('Forwarding stfagent on port ' + this.stfAgentPort) | |
try { | |
this.addLineToConnectionLog('Waiting for ' + StfAgentName) | |
await Shell.waitForProcessRunning(this.deviceId, StfAgentName, 10000) | |
this.addLineToConnectionLog('Waiting for ' + StfServiceName) | |
await Shell.waitForProcessRunning(this.deviceId, StfServiceName, 5000) | |
this.addLineToConnectionLog('STF processes running!') | |
} catch (e) { | |
this.addLineToConnectionLog('Error: ' + e.message) | |
} | |
} | |
await this.installMiniTouch(this.deviceId, data) | |
// For some reason sometimes the first connection try fails. We try up to 3 times. | |
const maxTries = 6 | |
for (var i = 0; i < maxTries; i++) { | |
try { | |
this.addLineToConnectionLog(`Try #${i + 1} to connect...`) | |
if (i > 2 && this.deviceData?.isEmulator) { | |
// if emulator hack didn't succeed, try without | |
this.useEmulatorHack = false | |
} | |
const { max1, max2, pid } = await this.startMinitouchProcess() | |
this.shortMax = max1 > max2 ? max2 : max1 | |
this.longMax = max1 > max2 ? max1 : max2 | |
this.pid = pid | |
break | |
} catch (e) { | |
log.warn(e) | |
if (this.childProcess) { | |
this.addLineToConnectionLog('Killing child process...') | |
this.childProcess.stdin.end() | |
this.childProcess.kill('SIGINT') | |
this.childProcess = undefined | |
} | |
} | |
} | |
if (!this.pid) { | |
console.groupEnd() | |
throw new MiniTouchError(`No touchsize info could be retrieved (${data.longInfoString})`) | |
} | |
sendEvent('minitouch-connected', { useEmulatorHack: this.useEmulatorHack, isEmulator: this.deviceData?.isEmulator }) | |
console.groupEnd() | |
} | |
/** | |
* @returns {Promise<{max1: Number, max2: Number, pid: Number}>} | |
*/ | |
async startMinitouchProcess() { | |
let max1, max2, pid | |
const self = this | |
var params = ['-s', this.deviceId, 'shell', '/data/local/tmp/minitouch', '-i'] | |
if (this.useEmulatorHack) { | |
// MiniTouch seems to have a bug, which causes to select input/6 instead of input/1. That's a workaround | |
this.addLineToConnectionLog('Activate emulator compatibility support...') | |
// One device has multiple touch devices | |
try { | |
const touchDeviceId = await this.getEmulatorTouchDeviceId(this.deviceId) | |
this.addLineToConnectionLog('Connect to emulator touch device ' + touchDeviceId) | |
params.push('-d') | |
params.push(touchDeviceId) | |
} catch (e) { | |
this.addLineToConnectionLog(e.message) | |
this.addLineToConnectionLog('Fallback to autodetect...') | |
} | |
} | |
const connectPromise = new Promise((resolve, reject) => { | |
const processChunk = chunk => { | |
this.addLineToConnectionLog('From Server: ' + chunk) | |
const lines = chunk.toString().split('\n') | |
var touchSizeInfo = lines.find(line => line.length > 0 && line[0] == '^') | |
var pidInfo = lines.find(line => line.length > 0 && line[0] == '$') | |
if (touchSizeInfo && touchSizeInfo.length > 0) { | |
this.addLineToConnectionLog('TouchSizeInfo: "' + touchSizeInfo + '"') | |
const infoArr = touchSizeInfo.split(' ') | |
max1 = parseInt(infoArr[2]) | |
max2 = parseInt(infoArr[3]) | |
} | |
if (pidInfo && pidInfo.length > 0) { | |
pid = pidInfo.split(' ')[1] | |
this.addLineToConnectionLog(`PID: ${pid}`) | |
} | |
if (max1 && max2 && pid) { | |
this.addLineToConnectionLog(`Display touch info found`) | |
resolve({ max1, max2, pid }) | |
} | |
} | |
const verbose = false | |
if (verbose) { | |
params.push('-v') | |
} | |
self.childProcess = Shell.open(Shell.getAdbPath(), params, 'MiniTouchProcess') | |
self.childProcess.stdout.on('data', chunk => { | |
processChunk(chunk) | |
}) | |
self.childProcess.stderr.on('data', chunk => { | |
// I don't know why, but minitouch is sending everything through the stderr stream... | |
processChunk(chunk) | |
}) | |
self.childProcess.on('close', () => { | |
this.addLineToConnectionLog('childProcess.onClose') | |
reject(new Error('Minitouch child process closed')) | |
}) | |
}) | |
// sometimes the stream just does not end (minitouch gets stuck while sending data?) | |
// in this case, we don't want to wait forever, just reject and retry | |
const timeoutPromise = new Promise((resolve, reject) => { | |
setTimeout(() => reject(new Error('Timeout while waiting for expected minitouch data')), 5000) | |
}) | |
return Promise.race([connectPromise, timeoutPromise]) | |
} | |
/** | |
* | |
* @param {string} deviceId | |
* @param {typeof DeviceData.Type} deviceData | |
*/ | |
async installMiniTouch(deviceId, deviceData) { | |
const path = '/data/local/tmp/' | |
await Shell.adbPushFile('./minitouch/libs/' + deviceData.abi + '/minitouch', path, deviceId) | |
// make executable. see bug https://github.com/openstf/minicap/issues/37 | |
await Shell.execADBCommand(['shell', 'chmod', '777', `${path}/minitouch`], this.deviceId, this.addLineToConnectionLog) | |
} | |
orientationTransform(xPerc, yPerc) { | |
let x | |
let y | |
const degrees = this.deviceData?.orientationDegrees | |
switch (degrees) { | |
case 270: | |
x = Math.round(this.shortMax * yPerc) | |
y = Math.round(this.longMax - this.longMax * xPerc) | |
//console.log('270 degrees ', x, y) | |
break | |
case 180: | |
x = Math.round(this.shortMax - this.shortMax * xPerc) | |
y = Math.round(this.longMax - this.longMax * yPerc) | |
//console.log('180 degrees ', x, y) | |
break | |
case 90: | |
x = Math.round(this.shortMax - this.shortMax * yPerc) | |
y = Math.round(this.longMax * xPerc) | |
//console.log('90 degrees ', x, y) | |
break | |
default: | |
x = Math.round(this.shortMax * xPerc) | |
y = Math.round(this.longMax * yPerc) | |
//console.log('default rotation ', x, y) | |
break | |
} | |
return { x, y } | |
} | |
sendToChildProcess(cmd) { | |
try { | |
// "Cannot call write after a stream was destroyed" | |
// it seems that sometimes the connection is lost (cable unplugged during replay? device rotated?) | |
// not sure why this is happening | |
// this actually looks like repeato is endlessly trying to reconnect: https://sentry.io/organizations/stephan-petzl/issues/2338218112/?environment=production&project=1554144&statsPeriod=14d | |
// here another one: https://sentry.io/organizations/stephan-petzl/issues/2501565450/?environment=production&project=1554144&query=is%3Aunresolved | |
this.childProcess.stdin.write(cmd) | |
} catch (e) { | |
const message = `Trying to send command, but childProcess was closed already. Connected state: ${Boolean(this.deviceId)} stdin.destroyed: ${ | |
this.childProcess?.stdin?.destroyed | |
} connected: ${this.childProcess?.connected}` | |
this.onError(new Error(message)) | |
} | |
} | |
async sendClick(xPerc, yPerc) { | |
log.debug('Sending click at ' + xPerc + '/' + yPerc) | |
const coord = this.orientationTransform(xPerc, yPerc) | |
var cmd = 'd 0 ' + coord.x + ' ' + coord.y + ' 50\n' | |
cmd += 'c\n' | |
cmd += 'u 0\n' | |
cmd += 'c\n' | |
this.sendToChildProcess(cmd) | |
} | |
async sendDown(xPerc, yPerc) { | |
log.debug('Sending DOWN at ' + xPerc + '/' + yPerc) | |
const coord = this.orientationTransform(xPerc, yPerc) | |
var cmd = 'd 0 ' + coord.x + ' ' + coord.y + ' 50\n' | |
cmd += 'c\n' | |
log.debug('send input: ', cmd) | |
this.sendToChildProcess(cmd) | |
} | |
async sendMove(xPerc, yPerc) { | |
log.debug('Sending MOVE at ' + xPerc + '/' + yPerc) | |
const coord = this.orientationTransform(xPerc, yPerc) | |
var cmd = 'm 0 ' + coord.x + ' ' + coord.y + ' 50\n' | |
cmd += 'c\n' | |
log.debug('send input: ', cmd) | |
this.sendToChildProcess(cmd) | |
} | |
async sendUp() { | |
log.debug('Sending UP') | |
var cmd = 'u 0\n' | |
cmd += 'c\n' | |
log.debug('send input: ', cmd) | |
this.sendToChildProcess(cmd) | |
} | |
// On emulators this seems to retrieve the right touch event id. Run "getevent -ilp" to see all the input interfaces | |
// MiniTouch does not seem to recognise the right touch ID by itself. This is the class for autodetect, it seems to have a bug: https://github.com/openstf/minitouch/blob/6a7836cd3e9161691b18719db3c7da12b44f540f/jni/minitouch/minitouch.c | |
getEmulatorTouchDeviceId = async deviceId => { | |
let lines | |
try { | |
// we get all the lines until "multi_touch_1" is found. This is what we are looking for, since I observed that the correct input device has the name "virtio_input_multi_touch_1" | |
lines = await Shell.execAdbShellCommand('getevent -p', deviceId, 'multi_touch_1"') | |
console.log(`lines`, lines) | |
const lineArr = lines.split('\n') | |
// output looks like this. We want to get the "/dev/input/eventX" part. | |
/** | |
* ... | |
* add device 7: /dev/input/event2 | |
* name: "virtio_input_multi_touch_1" | |
* ... | |
*/ | |
const index1 = lineArr.findIndex(line => line.indexOf('multi_touch_1"') !== -1) | |
// prev line of the line we found is what we need | |
const touchDeviceLine = lineArr[index1 - 1] | |
const eventId = touchDeviceLine | |
.trim() | |
.split(' ') | |
.pop() // "add device 7: /dev/input/event2" -> "/dev/input/event2" | |
console.log(`touchDeviceLine`, touchDeviceLine) | |
console.log(`Found input interface ID`, eventId) | |
return eventId | |
} catch (e) { | |
throw new Error('No emulator touch interface found. Lines: ' + lines + ' \nError: ' + e.message) | |
} | |
} | |
async disconnect() { | |
this.groupCollapsed('Disconnecting MiniTouch...') | |
try { | |
Shell.execADBCommand(['shell', 'am', 'force-stop', StfServiceName], this.deviceId, this.addLineToConnectionLog).catch(e => log.debug(e)) | |
if (this.childProcess) { | |
this.addLineToConnectionLog('Killing child process...') | |
this.childProcess.stdin.end() | |
this.childProcess.kill('SIGINT') | |
this.childProcess = undefined | |
} | |
if (this.stfServiceAgent) { | |
this.addLineToConnectionLog('Killing stfServiceAgent process...') | |
this.stfServiceAgent.stdin.end() | |
this.stfServiceAgent.kill('SIGINT') | |
this.stfServiceAgent = undefined | |
} | |
await Shell.adbKillProcess(MiniTouchProcessName, this.deviceId) | |
await Shell.adbKillProcess(StfAgentName, this.deviceId) | |
await Shell.adbKillProcess(StfServiceName, this.deviceId) | |
const ProcessKillTimeout = 5000 | |
try { | |
await Shell.execADBCommand(['forward', '--remove', 'tcp:' + this.port], this.deviceId, this.addLineToConnectionLog) | |
} catch (e) { | |
console.debug(e) | |
} | |
try { | |
await Shell.execADBCommand(['forward', '--remove', 'tcp:' + this.stfAgentPort], this.deviceId, this.addLineToConnectionLog) | |
} catch (e) { | |
console.debug(e) | |
} | |
/*await Shell.waitForProcessStopped(this.deviceId, MiniTouchProcessName, ProcessKillTimeout) | |
await Shell.waitForProcessStopped(this.deviceId, StfAgentName, ProcessKillTimeout) | |
await Shell.waitForProcessStopped(this.deviceId, StfServiceName, ProcessKillTimeout)*/ | |
this.deviceId = null | |
this.addLineToConnectionLog('Disconnected.') | |
} catch (e) { | |
this.addLineToConnectionLog('Error while disconnecting: ' + e.message) | |
} | |
console.groupEnd() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment