diff --git a/lib/roles/app.js b/lib/roles/app.js index 1d652eed..0db1c246 100644 --- a/lib/roles/app.js +++ b/lib/roles/app.js @@ -347,6 +347,24 @@ module.exports = function(options) { ]) }) // Transactions + .on('clipboard.paste', function(channel, responseChannel, data) { + joinChannel(responseChannel) + push.send([ + channel + , wireutil.envelope(new wire.PasteMessage( + data.text + )) + ]) + }) + .on('clipboard.copy', function(channel, responseChannel, data) { + joinChannel(responseChannel) + push.send([ + channel + , wireutil.envelope(new wire.CopyMessage( + data.text + )) + ]) + }) .on('device.identify', function(channel, responseChannel) { push.send([ channel diff --git a/lib/roles/device.js b/lib/roles/device.js index 3ad21d08..f3eb1ee1 100644 --- a/lib/roles/device.js +++ b/lib/roles/device.js @@ -18,6 +18,7 @@ module.exports = function(options) { .dependency(require('./device/plugins/display')) .dependency(require('./device/plugins/http')) .dependency(require('./device/plugins/input')) + .dependency(require('./device/plugins/clipboard')) .dependency(require('./device/plugins/logcat')) .dependency(require('./device/plugins/shell')) .dependency(require('./device/plugins/touch')) diff --git a/lib/roles/device/plugins/clipboard.js b/lib/roles/device/plugins/clipboard.js new file mode 100644 index 00000000..0e493c29 --- /dev/null +++ b/lib/roles/device/plugins/clipboard.js @@ -0,0 +1,70 @@ +var syrup = require('syrup') + +var logger = require('../../../util/logger') +var wire = require('../../../wire') +var wireutil = require('../../../wire/util') + +module.exports = syrup.serial() + .dependency(require('../support/router')) + .dependency(require('../support/push')) + .dependency(require('./input')) + .define(function(options, router, push, input) { + var log = logger.createLogger('device:plugins:clipboard') + + router.on(wire.PasteMessage, function(channel, message) { + log.info('Pasting "%s" to clipboard', message.text) + var seq = 0 + input.paste(message.text) + .then(function() { + push.send([ + channel + , wireutil.envelope(new wire.TransactionDoneMessage( + options.serial + , seq++ + , true + )) + ]) + }) + .catch(function(err) { + log.error('Paste failed', err.stack) + push.send([ + channel + , wireutil.envelope(new wire.TransactionDoneMessage( + options.serial + , seq++ + , false + , err.message + )) + ]) + }) + }) + + router.on(wire.CopyMessage, function(channel) { + log.info('Copying clipboard contents') + var seq = 0 + input.copy() + .then(function(content) { + push.send([ + channel + , wireutil.envelope(new wire.TransactionDoneMessage( + options.serial + , seq++ + , true + , content + )) + ]) + }) + .catch(function(err) { + log.error('Copy failed', err.stack) + push.send([ + channel + , wireutil.envelope(new wire.TransactionDoneMessage( + options.serial + , seq++ + , false + , err.message + )) + ]) + }) + }) + }) diff --git a/lib/roles/device/plugins/input.js b/lib/roles/device/plugins/input.js index 4ce493a8..34f0ad92 100644 --- a/lib/roles/device/plugins/input.js +++ b/lib/roles/device/plugins/input.js @@ -2,7 +2,7 @@ var util = require('util') var syrup = require('syrup') var split = require('split') -var ByteBuffer = require('protobufjs/node_modules/bytebuffer') +var Promise = require('bluebird') var wire = require('../../../wire') var wireutil = require('../../../wire/util') @@ -10,26 +10,29 @@ var devutil = require('../../../util/devutil') var keyutil = require('../../../util/keyutil') var streamutil = require('../../../util/streamutil') var logger = require('../../../util/logger') +var ms = require('../../../wire/messagestream') module.exports = syrup.serial() .dependency(require('../support/adb')) .dependency(require('../support/router')) .dependency(require('../support/push')) .dependency(require('../support/quit')) - .dependency(require('../resources/inputagent')) + .dependency(require('../resources/service')) .define(function(options, adb, router, push, quit, apk) { var log = logger.createLogger('device:plugins:input') + var serviceQueue = [] var agent = { socket: null + , writer: null , port: 1090 } var service = { socket: null + , writer: null + , reader: null , port: 1100 - , startAction: 'jp.co.cyberagent.stf.input.agent.InputService.ACTION_START' - , stopAction: 'jp.co.cyberagent.stf.input.agent.InputService.ACTION_STOP' } function openAgent() { @@ -40,8 +43,7 @@ module.exports = syrup.serial() }) .then(function() { return adb.shell(options.serial, util.format( - "export CLASSPATH='%s';" + - " exec app_process /system/bin '%s'" + "export CLASSPATH='%s'; exec app_process /system/bin '%s'" , apk.path , apk.main )) @@ -65,6 +67,8 @@ module.exports = syrup.serial() }) .then(function(conn) { agent.socket = conn + agent.writer = new ms.DelimitingStream() + agent.writer.pipe(conn) conn.on('error', function(err) { log.fatal('InputAgent socket had an error', err.stack) quit.fatal() @@ -137,62 +141,166 @@ module.exports = syrup.serial() return devutil.waitForPortToFree(adb, options.serial, service.port) }) .then(function() { - return callService(util.format("-a '%s'", service.startAction)) + return callService(util.format("-a '%s'", apk.startAction)) }) .then(function() { return devutil.waitForPort(adb, options.serial, service.port) }) .then(function(conn) { service.socket = conn + service.reader = conn.pipe(new ms.DelimitedStream()) + service.reader.on('data', function(data) { + if (serviceQueue.length) { + var resolver = serviceQueue.shift() + resolver.resolve(data) + } + else { + log.warn('Unexpected data from service', data) + } + }) + service.writer = new ms.DelimitingStream() + service.writer.pipe(conn) conn.on('error', function(err) { - log.fatal('InputService socket had an error', err.stack) - quit.fatal() - }) - conn.on('end', function() { - log.fatal('InputService socket ended') - quit.fatal() - }) + log.fatal('InputService socket had an error', err.stack) + quit.fatal() + }) + .on('end', function() { + log.fatal('InputService socket ended') + quit.fatal() + }) }) } function stopService() { - return callService(util.format("-a '%s'", service.stopAction)) + return callService(util.format("-a '%s'", apk.stopAction)) } function sendInputEvent(event) { - var lengthBuffer = new ByteBuffer() - , messageBuffer = new apk.proto.InputEvent(event).encode() + agent.writer.write(new apk.agentProto.InputEvent(event).encodeNB()) + } - // Delimiter - lengthBuffer.writeVarint32(messageBuffer.length) - - agent.socket.write(Buffer.concat([ - lengthBuffer.toBuffer() - , messageBuffer.toBuffer() - ])) + function version() { + return runServiceCommand( + apk.serviceProto.RequestType.VERSION + , new apk.serviceProto.VersionRequest() + ) + .then(function(data) { + var response = apk.serviceProto.VersionResponse.decode(data) + if (response.success) { + return response.version + } + throw new Error('Unable to retrieve version') + }) } function unlock() { - service.socket.write('unlock\n') + return runServiceCommand( + apk.serviceProto.RequestType.SET_KEYGUARD_STATE + , new apk.serviceProto.SetKeyguardStateRequest(false) + ) + .then(function(data) { + var response = apk.serviceProto.SetKeyguardStateResponse.decode(data) + if (!response.success) { + throw new Error('Unable to unlock device') + } + }) } function lock() { - service.socket.write('lock\n') + return runServiceCommand( + apk.serviceProto.RequestType.SET_KEYGUARD_STATE + , new apk.serviceProto.SetKeyguardStateRequest(true) + ) + .then(function(data) { + var response = apk.serviceProto.SetKeyguardStateResponse.decode(data) + if (!response.success) { + throw new Error('Unable to lock device') + } + }) } function acquireWakeLock() { - service.socket.write('acquire wake lock\n') + return runServiceCommand( + apk.serviceProto.RequestType.SET_WAKE_LOCK + , new apk.serviceProto.SetWakeLockRequest(true) + ) + .then(function(data) { + var response = apk.serviceProto.SetWakeLockResponse.decode(data) + if (!response.success) { + throw new Error('Unable to acquire WakeLock') + } + }) } function releaseWakeLock() { - service.socket.write('release wake lock\n') + return runServiceCommand( + apk.serviceProto.RequestType.SET_WAKE_LOCK + , new apk.serviceProto.SetWakeLockRequest(false) + ) + .then(function(data) { + var response = apk.serviceProto.SetWakeLockResponse.decode(data) + if (!response.success) { + throw new Error('Unable to release WakeLock') + } + }) } function identity() { - service.socket.write(util.format( - 'show identity %s\n' - , options.serial - )) + return runServiceCommand( + apk.serviceProto.RequestType.IDENTIFY + , new apk.serviceProto.IdentifyRequest(options.serial) + ) + .then(function(data) { + var response = apk.serviceProto.IdentifyResponse.decode(data) + if (!response.success) { + throw new Error('Unable to identify device') + } + }) + } + + function setClipboard(text) { + return runServiceCommand( + apk.serviceProto.RequestType.SET_CLIPBOARD + , new apk.serviceProto.SetClipboardRequest( + apk.serviceProto.ClipboardType.TEXT + , text + ) + ) + .then(function(data) { + var response = apk.serviceProto.SetClipboardResponse.decode(data) + if (!response.success) { + throw new Error('Unable to set clipboard') + } + }) + } + + function getClipboard() { + return runServiceCommand( + apk.serviceProto.RequestType.GET_CLIPBOARD + , new apk.serviceProto.GetClipboardRequest( + apk.serviceProto.ClipboardType.TEXT + ) + ) + .then(function(data) { + var response = apk.serviceProto.GetClipboardResponse.decode(data) + if (response.success) { + switch (response.type) { + case apk.serviceProto.ClipboardType.TEXT: + return response.text + } + } + throw new Error('Unable to get clipboard') + }) + } + + function runServiceCommand(type, cmd) { + var resolver = Promise.defer() + service.writer.write(new apk.serviceProto.RequestEnvelope( + type + , cmd.encodeNB() + ).encodeNB()) + serviceQueue.push(resolver) + return resolver.promise } return openAgent() @@ -242,6 +350,17 @@ module.exports = syrup.serial() , acquireWakeLock: acquireWakeLock , releaseWakeLock: releaseWakeLock , identity: identity + , paste: function(text) { + return setClipboard(text) + .then(function() { + sendInputEvent({ + action: 2 + , keyCode: adb.Keycode.KEYCODE_V + , ctrlKey: true + }) + }) + } + , copy: getClipboard } }) }) diff --git a/lib/roles/device/resources/inputagent.js b/lib/roles/device/resources/service.js similarity index 66% rename from lib/roles/device/resources/inputagent.js rename to lib/roles/device/resources/service.js index b61d951b..4ad6aea5 100644 --- a/lib/roles/device/resources/inputagent.js +++ b/lib/roles/device/resources/service.js @@ -11,16 +11,21 @@ var logger = require('../../../util/logger') module.exports = syrup.serial() .dependency(require('../support/adb')) .define(function(options, adb) { - var log = logger.createLogger('device:resources:inputagent') + var log = logger.createLogger('device:resources:service') var resource = { requiredVersion: '~0.2.0' - , pkg: 'jp.co.cyberagent.stf.input.agent' - , main: 'jp.co.cyberagent.stf.input.agent.InputAgent' - , apk: pathutil.vendor('InputAgent/InputAgent.apk') - , proto: ProtoBuf.loadProtoFile( - pathutil.vendor('InputAgent/proto/agent.proto') - ).build().jp.co.cyberagent.stf.input.agent.proto + , pkg: 'jp.co.cyberagent.stf' + , main: 'jp.co.cyberagent.stf.InputAgent' + , apk: pathutil.vendor('STFService/STFService.apk') + , agentProto: ProtoBuf.loadProtoFile( + pathutil.vendor('STFService/proto/agent.proto') + ).build().jp.co.cyberagent.stf.proto + , serviceProto: ProtoBuf.loadProtoFile( + pathutil.vendor('STFService/proto/service.proto') + ).build().jp.co.cyberagent.stf.proto + , startAction: 'jp.co.cyberagent.stf.ACTION_START' + , stopAction: 'jp.co.cyberagent.stf.ACTION_STOP' } function getPath() { @@ -34,7 +39,7 @@ module.exports = syrup.serial() } function install() { - log.info('Checking whether we need to install InputAgent.apk') + log.info('Checking whether we need to install STFService') return getPath() .then(function(installedPath) { log.info('Running version check') @@ -49,6 +54,10 @@ module.exports = syrup.serial() .timeout(10000) .then(function(buffer) { var version = buffer.toString() + throw new Error(util.format( + 'Incompatible version %s' + , version + )) if (semver.satisfies(version, resource.requiredVersion)) { return installedPath } @@ -62,7 +71,7 @@ module.exports = syrup.serial() }) }) .catch(function() { - log.info('Installing InputAgent.apk') + log.info('Installing STFService') return adb.install(options.serial, resource.apk) .then(function() { return getPath() @@ -72,12 +81,8 @@ module.exports = syrup.serial() return install() .then(function(path) { - log.info('InputAgent.apk up to date') - return { - path: path - , pkg: resource.pkg - , main: resource.main - , proto: resource.proto - } + log.info('STFService up to date') + resource.path = path + return resource }) }) diff --git a/lib/roles/device/support/adb.js b/lib/roles/device/support/adb.js index c11aff54..588cb8b6 100644 --- a/lib/roles/device/support/adb.js +++ b/lib/roles/device/support/adb.js @@ -9,6 +9,7 @@ module.exports = syrup.serial() .define(function(options) { var log = logger.createLogger('device:support:adb') var adb = adbkit.createClient() + adb.Keycode = adbkit.Keycode function ensureBootComplete() { return promiseutil.periodicNotify( diff --git a/lib/wire/wire.proto b/lib/wire/wire.proto index 3434ad43..c5a1cc6d 100644 --- a/lib/wire/wire.proto +++ b/lib/wire/wire.proto @@ -1,6 +1,7 @@ // Message wrapper enum MessageType { + CopyMessage = 33; DeviceAbsentMessage = 1; DeviceHeartbeatMessage = 28; DeviceIdentityMessage = 2; @@ -21,6 +22,7 @@ enum MessageType { LaunchActivityMessage = 31; LeaveGroupMessage = 15; LogcatApplyFiltersMessage = 16; + PasteMessage = 32; ProbeMessage = 17; ShellCommandMessage = 18; ShellKeepAliveMessage = 19; @@ -216,6 +218,13 @@ message TypeMessage { required string text = 1; } +message PasteMessage { + required string text = 1; +} + +message CopyMessage { +} + enum KeyCode { HOME = 3; BACK = 4; diff --git a/res/app/components/stf/control/control-service.js b/res/app/components/stf/control/control-service.js index 1215cd36..08a58d6f 100644 --- a/res/app/components/stf/control/control-service.js +++ b/res/app/components/stf/control/control-service.js @@ -78,6 +78,20 @@ module.exports = function ControlServiceFactory( }) } + this.paste = function(text) { + var tx = TransactionService.create(target) + socket.emit('clipboard.paste', channel, tx.channel, { + text: text + }) + return tx + } + + this.copy = function() { + var tx = TransactionService.create(target) + socket.emit('clipboard.copy', channel, tx.channel) + return tx + } + this.shell = function(command) { var tx = TransactionService.create(target) socket.emit('shell.command', channel, tx.channel, { diff --git a/res/app/components/stf/screen/screen-directive.js b/res/app/components/stf/screen/screen-directive.js index f18b3a91..02a8692c 100644 --- a/res/app/components/stf/screen/screen-directive.js +++ b/res/app/components/stf/screen/screen-directive.js @@ -86,7 +86,7 @@ module.exports = function DeviceScreenDirective($document, ScalingService) { function pasteListener(e) { e.preventDefault() // no need to change value - scope.control.type(e.clipboardData.getData('text/plain')) + scope.control.paste(e.clipboardData.getData('text/plain')) } function maybeLoadScreen() { diff --git a/vendor/InputAgent/InputAgent.apk b/vendor/InputAgent/InputAgent.apk deleted file mode 100644 index 373ba8be..00000000 Binary files a/vendor/InputAgent/InputAgent.apk and /dev/null differ diff --git a/vendor/STFService/STFService.apk b/vendor/STFService/STFService.apk new file mode 100644 index 00000000..8f310863 Binary files /dev/null and b/vendor/STFService/STFService.apk differ diff --git a/vendor/InputAgent/proto/agent.proto b/vendor/STFService/proto/agent.proto similarity index 91% rename from vendor/InputAgent/proto/agent.proto rename to vendor/STFService/proto/agent.proto index aeadd9ae..9fd37eb4 100644 --- a/vendor/InputAgent/proto/agent.proto +++ b/vendor/STFService/proto/agent.proto @@ -1,4 +1,4 @@ -package jp.co.cyberagent.stf.input.agent.proto; +package jp.co.cyberagent.stf.proto; option java_outer_classname = "AgentProto"; diff --git a/vendor/STFService/proto/service.proto b/vendor/STFService/proto/service.proto new file mode 100644 index 00000000..6a951da6 --- /dev/null +++ b/vendor/STFService/proto/service.proto @@ -0,0 +1,92 @@ +package jp.co.cyberagent.stf.proto; + +option java_outer_classname = "ServiceProto"; + +enum RequestType { + VERSION = 0; + SET_KEYGUARD_STATE = 1; + SET_WAKE_LOCK = 2; + SET_CLIPBOARD = 3; + GET_CLIPBOARD = 4; + GET_PROPERTIES = 6; + IDENTIFY = 7; +} + +message RequestEnvelope { + required RequestType type = 1; + required bytes request = 2; +} + +message ResponseEnvelope { + required bool success = 1; + required bytes response = 2; +} + +message VersionRequest { +} + +message VersionResponse { + required bool success = 1; + optional string version = 2; +} + +message SetKeyguardStateRequest { + required bool enabled = 1; +} + +message SetKeyguardStateResponse { + required bool success = 1; +} + +message SetWakeLockRequest { + required bool enabled = 1; +} + +message SetWakeLockResponse { + required bool success = 1; +} + +enum ClipboardType { + TEXT = 1; +} + +message SetClipboardRequest { + required ClipboardType type = 1; + optional string text = 2; +} + +message SetClipboardResponse { + required bool success = 1; +} + +message GetClipboardRequest { + required ClipboardType type = 1; +} + +message GetClipboardResponse { + required bool success = 1; + optional ClipboardType type = 2; + optional string text = 3; +} + +message Property { + required string name = 1; + required string value = 2; +} + +message GetPropertiesRequest { + repeated string properties = 1; +} + +message GetPropertiesResponse { + required bool success = 1; + repeated Property properties = 2; +} + +message IdentifyRequest { + required string serial = 1; +} + +message IdentifyResponse { + required bool success = 1; +}