Skip to content

Instantly share code, notes, and snippets.

@stoefln
Last active May 26, 2022 15:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stoefln/ede709c0ec07d158d8533229f22705ae to your computer and use it in GitHub Desktop.
Save stoefln/ede709c0ec07d158d8533229f22705ae to your computer and use it in GitHub Desktop.
//@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