adb_driver 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cc283d720879afb304052b394d127b396c971ff784cb0d6dab39051cc50f88c7
4
+ data.tar.gz: ad04b403c6f63cce562a78150d7db53f7b8eabb8e89d5aeb1d27732377c3e6ba
5
+ SHA512:
6
+ metadata.gz: 790517ac22172520ee9b870cc5a290ae7cb32668b2a5e9be231db9996d9a51f0a5733bbf2245a5439c942b7603977bb0954b38adaf35bfd6f9d38b6a39869581
7
+ data.tar.gz: d446dd2027f102329d4c00ac0f432e476eb965136a6328681830bc714ec92c571e5dca6fc5ae2ac8e5d8f4251cf2abd5c4d4c21c9e136291acde77a6d2c52a73
data/lib/adb_driver.rb ADDED
@@ -0,0 +1,11 @@
1
+ require_relative 'adb_driver/wait'
2
+
3
+ require_relative 'adb_driver/element'
4
+ require_relative 'adb_driver/finder'
5
+ require_relative 'adb_driver/driver'
6
+ require_relative 'adb_driver/navigation'
7
+ require_relative 'adb_driver/error'
8
+
9
+ require_relative 'adb_driver/dsl'
10
+ require_relative 'adb_driver/source_cleaner'
11
+ require_relative 'adb_driver/adb'
@@ -0,0 +1,123 @@
1
+ require 'timeout'
2
+
3
+ module Adb
4
+ extend Timeout
5
+ extend self
6
+
7
+ def execute_command(command, timeout_in_seconds = 10)
8
+ timeout(timeout_in_seconds) { `adb #{command}` }
9
+ end
10
+
11
+ def app_version(package_name)
12
+ output = execute_command("shell dumpsys package #{package_name}")
13
+ version_line = output.lines.grep(/versionName/).first.strip
14
+ version_line[/=(.*)/, 1]
15
+ end
16
+
17
+ def package_exists?(package_name)
18
+ execute_command('shell pm list packages').lines.grep(/#{package_name}\s*$/).one?
19
+ end
20
+
21
+ def android_5_or_greater?
22
+ android_version[0].to_i >= 5
23
+ end
24
+
25
+ def android_6_or_greater?
26
+ android_version[0].to_i >= 6
27
+ end
28
+
29
+ def android_6?
30
+ android_version.start_with?('6')
31
+ end
32
+
33
+ def android_7?
34
+ android_version.start_with?('7')
35
+ end
36
+
37
+ def real_device?
38
+ output = execute_command('devices -l')
39
+ output = output.lines.grep(/#{ENV['ANDROID_SERIAL']}/).first
40
+ output.include?('usb')
41
+ end
42
+
43
+ def emulator?
44
+ !real_device?
45
+ end
46
+
47
+ def samsung?
48
+ brand == 'samsung'
49
+ end
50
+
51
+ def htc?
52
+ brand == 'htc'
53
+ end
54
+
55
+ def asus?
56
+ brand == 'asus'
57
+ end
58
+
59
+ def lenovo?
60
+ brand == 'Lenovo'
61
+ end
62
+
63
+ def density
64
+ @density ||= execute_command('shell getprop ro.sf.lcd_density').to_i
65
+ end
66
+
67
+ def brand
68
+ execute_command('shell getprop ro.product.brand').strip
69
+ end
70
+
71
+ def model
72
+ execute_command('shell getprop ro.product.model').strip
73
+ end
74
+
75
+ def remove_package(name)
76
+ return unless package_exists?(name)
77
+ `adb uninstall #{name}`
78
+ fail "#{name} package wasn't removed" if package_exists?(name)
79
+ end
80
+
81
+ def restart_adb
82
+ `adb kill-server; adb start-server`
83
+ end
84
+
85
+ def portrait?
86
+ @orientation_enum ||= `adb shell dumpsys input`.lines.find { |l| l =~ /SurfaceOrientation/ }.strip[-1].to_i
87
+ @orientation_enum.even?
88
+ end
89
+
90
+ def connected_device_udid
91
+ case
92
+ when devices.none? then fail('No devices detected')
93
+ when devices.one? then devices.first.udid
94
+ when devices.count > 1 && emulators.none? then fail('Several devices detected. Set ANDROID_SERIAL to pick one')
95
+ when devices.count > 1 && emulators.one? then emulators.first.udid
96
+ when devices.count > 1 && emulators.count > 1 then fail('Several emulators detected. Close all but one')
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def android_version
103
+ execute_command('shell getprop ro.build.version.release').lines.last.strip
104
+ end
105
+
106
+ def devices
107
+ @device_list ||= begin
108
+ execute_command('devices -l').lines.grep(/model/).inject([]) do |dl, device_string|
109
+ dl << Device.new(device_string[/^\S+/])
110
+ end
111
+ end
112
+ end
113
+
114
+ def emulators
115
+ @emulators_list ||= begin
116
+ execute_command('devices -l').lines.grep(/model/).grep_v(/usb/).inject([]) do |dl, device_string|
117
+ dl << Device.new(device_string[/^\S+/])
118
+ end
119
+ end
120
+ end
121
+
122
+ Device = Struct.new(:udid)
123
+ end
@@ -0,0 +1,35 @@
1
+ require 'logger'
2
+
3
+ module AdbDriver
4
+ class Driver
5
+ include Finder
6
+ include Wait
7
+
8
+ SCREENSHOT_TIMEOUT = 5
9
+
10
+ attr_reader :logger
11
+
12
+ def initialize
13
+ @logger = Logger.new('adb_driver.log')
14
+ @logger.level = Logger::DEBUG
15
+ @logger.info 'Initializing Adb driver'
16
+ end
17
+
18
+ def save_screenshot(filepath)
19
+ if Adb.android_5_or_greater?
20
+ wait(SCREENSHOT_TIMEOUT) { `adb exec-out screencap -p > #{filepath}` }
21
+ else
22
+ wait(SCREENSHOT_TIMEOUT) { `adb shell screencap -p /sdcard/screenshot.png; adb pull /sdcard/screenshot.png #{filepath}` }
23
+ end
24
+ rescue Wait::Error => e
25
+ raise e.class, 'Cannot take a screenshot'
26
+ end
27
+
28
+ def navigate
29
+ @navigation ||= Navigation.new
30
+ end
31
+
32
+ def quit
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,225 @@
1
+ module DSL
2
+ def active?
3
+ new.active?
4
+ end
5
+
6
+ def button(name, options)
7
+ define_active_method(name) if options.delete(:distinctive)
8
+ define_scroll_to_bottom_method(name) if options.delete(:bottom)
9
+ wait_for_class = options.delete(:wait_for_class)
10
+ wait_for_class_timeout = options.delete(:wait_for_class_timeout)
11
+ index = options.delete(:index)
12
+
13
+ locator = options
14
+ define_name_method(name, wait_for_class, wait_for_class_timeout)
15
+ define_query_method(name, locator, index)
16
+ define_button_method(name, locator, index)
17
+ end
18
+
19
+ def buttons(locator, &block)
20
+ ButtonsBuilder.new(locator, &block)
21
+ end
22
+
23
+ def radio_buttons(locator, &block)
24
+ ButtonsBuilder.new(locator, &block)
25
+ define_selected_method
26
+ end
27
+
28
+ def text_field(name, options)
29
+ define_active_method(name) if options.delete(:distinctive)
30
+
31
+ locator = options
32
+ define_text_field_method(name, locator)
33
+ define_query_method(name, locator)
34
+ define_setter(name, locator)
35
+ define_getter(name, locator)
36
+ end
37
+
38
+ def switch(name, options)
39
+ define_active_method(name) if options.delete(:distinctive)
40
+ wait_for_class = options.delete(:wait_for_class)
41
+
42
+ locator = options
43
+ define_query_method(name, locator)
44
+ define_switch_methods(name, locator, wait_for_class)
45
+ end
46
+ alias checkbox switch
47
+
48
+ def view(name, options)
49
+ define_active_method(name) if options.delete(:distinctive)
50
+
51
+ locator = options
52
+ define_query_method(name, locator)
53
+ define_view_method(name, locator)
54
+ end
55
+
56
+ private
57
+
58
+ def define_active_method(name)
59
+ define_method(:active?) do
60
+ send("#{name}?")
61
+ end
62
+ end
63
+
64
+ def define_scroll_to_bottom_method(name)
65
+ define_method(:scroll_to_bottom) do
66
+ start = Time.now
67
+ until send("#{name}?")
68
+ fling_down
69
+ sleep 0.1
70
+ fail "Unable to scroll to the bottom (#{name} button)" if Time.now > start + 60
71
+ end
72
+ end
73
+ end
74
+
75
+ def define_name_method(name, wait_for_class, wait_for_class_timeout)
76
+ wait_for_class_timeout = 10 unless wait_for_class_timeout
77
+
78
+ define_method(name) do
79
+ send("#{name}_button").click
80
+ if wait_for_class
81
+ wait(wait_for_class_timeout, "Screen #{wait_for_class} hasn't became active") do
82
+ self.class.const_get(wait_for_class).active?
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def define_text_field_method(name, locator)
89
+ define_method("#{name}_text_field") do
90
+ begin
91
+ find_element(locator)
92
+ rescue Selenium::WebDriver::Error::NoSuchElementError => e
93
+ fail e.class, "'#{name}' text field cannot be found using #{locator}", caller.reject { |line| line =~ /#{__FILE__}/ }
94
+ end
95
+ end
96
+ end
97
+
98
+ def define_query_method(name, locator, index = nil)
99
+ define_method("#{name}?") do
100
+ if index
101
+ !!find_elements(locator)[index] || false
102
+ else
103
+ begin
104
+ !!find_element(locator)
105
+ rescue Selenium::WebDriver::Error::NoSuchElementError
106
+ false
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def define_button_method(name, locator, index)
113
+ define_method("#{name}_button") do
114
+ if index
115
+ button = find_elements(locator)[index]
116
+ unless button
117
+ fail Selenium::WebDriver::Error::NoSuchElementError,
118
+ "'#{name}' button cannot be found using #{locator} with index #{index}",
119
+ caller.reject { |line| line =~ /#{__FILE__}/ }
120
+ end
121
+ button
122
+ else
123
+ begin
124
+ find_element(locator)
125
+ rescue Selenium::WebDriver::Error::NoSuchElementError => e
126
+ fail e.class, "'#{name}' button cannot be found using #{locator}", caller.reject { |line| line =~ /#{__FILE__}/ }
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def define_view_method(name, locator)
133
+ define_method("#{name}_view") do
134
+ begin
135
+ find_element(locator)
136
+ rescue Selenium::WebDriver::Error::NoSuchElementError
137
+ fail %(Element "#{name}" with locator "#{locator}" wasn't found)
138
+ end
139
+ end
140
+ end
141
+
142
+ def define_setter(name, locator)
143
+ define_method("#{name}=") do |text|
144
+ find_element(locator).send_keys text
145
+ end
146
+ end
147
+
148
+ def define_getter(name, locator, index = nil)
149
+ define_method(name) do
150
+ if index
151
+ find_elements(locator)[index].name
152
+ else
153
+ find_element(locator).name
154
+ end
155
+ end
156
+ end
157
+
158
+ def define_switch_methods(name, locator, wait_for_class)
159
+ define_method("#{name}_switch") do
160
+ find_element(locator)
161
+ end
162
+
163
+ define_method("toggle_#{name}") do
164
+ find_element(locator).click
165
+ end
166
+
167
+ define_method("#{name}_on?") do
168
+ find_element(locator).attribute(:checked) == 'true'
169
+ end
170
+ alias_method "#{name}_selected?".to_sym, "#{name}_on?".to_sym
171
+
172
+ define_method("turn_on_#{name}") do
173
+ unless send("#{name}_on?")
174
+ send("toggle_#{name}")
175
+ if wait_for_class
176
+ wait(5, "Screen #{wait_for_class} hasn't became active") do
177
+ Module.const_get(wait_for_class).active?
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ define_method("turn_off_#{name}") do
184
+ if send("#{name}_on?")
185
+ send("toggle_#{name}")
186
+ if wait_for_class
187
+ wait(5, "Screen #{wait_for_class} hasn't became active") do
188
+ Module.const_get(wait_for_class).active?
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ def define_selected_method
196
+ button_methods = instance_methods.grep(/_button/)
197
+ define_method(:selected) do
198
+ result = button_methods.map do |button_method|
199
+ send(button_method).checked? && button_method.to_s.sub(/_button/, '').to_sym
200
+ end
201
+ result.grep_v(false).first
202
+ end
203
+ end
204
+
205
+ class ButtonsBuilder
206
+ def initialize(locator, &block)
207
+ @locator = locator
208
+ @block = block
209
+ @index = 0
210
+ instance_exec(&block)
211
+ end
212
+
213
+ def method_missing(method, *args)
214
+ button_name = args[0]
215
+ button_params = args[1] || {}
216
+
217
+ unless button_params.key?(:index)
218
+ button_params[:index] = @index
219
+ @index += 1
220
+ end
221
+
222
+ @block.binding.receiver.button button_name, @locator.merge(button_params)
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,72 @@
1
+ class Element
2
+ Point = Struct.new(:x, :y)
3
+ Size = Struct.new(:width, :height)
4
+
5
+ def initialize(xml_representation)
6
+ @xml_representation = xml_representation
7
+ end
8
+
9
+ def click
10
+ `adb shell input tap #{center.x} #{center.y}`
11
+ end
12
+
13
+ def long_click
14
+ `adb shell input swipe #{center.x} #{center.y} #{center.x} #{center.y} 800`
15
+ end
16
+
17
+ def send_keys(text)
18
+ click
19
+ sleep 2
20
+ text.scan(/.{1,10}/).each do |part|
21
+ `adb shell "input text '#{part}'"`
22
+ end
23
+ end
24
+
25
+ def text
26
+ @xml_representation.attributes['text']
27
+ end
28
+
29
+ def checked?
30
+ @xml_representation.attributes['checked'] == 'true'
31
+ end
32
+
33
+ def content_desc
34
+ @xml_representation.attributes['content-desc']
35
+ end
36
+
37
+ def top_left
38
+ Point.new(coordinates[0], coordinates[1])
39
+ end
40
+ alias_method :location, :top_left
41
+
42
+ def lower_right
43
+ Point.new(coordinates[2], coordinates[3])
44
+ end
45
+
46
+ def width
47
+ lower_right.x - top_left.x
48
+ end
49
+
50
+ def height
51
+ lower_right.y - top_left.y
52
+ end
53
+
54
+ def center
55
+ Point.new(top_left.x + width / 2, top_left.y + height / 2)
56
+ end
57
+
58
+ def size
59
+ Size.new(width, height)
60
+ end
61
+
62
+ def class_name
63
+ @xml_representation.attributes['class']
64
+ end
65
+
66
+ private
67
+
68
+ def coordinates
69
+ @coordinates ||= @xml_representation.attributes['bounds'].scan(/\d+/).map(&:to_i)
70
+ end
71
+ end
72
+
@@ -0,0 +1,16 @@
1
+ unless defined?(Selenium)
2
+ module Selenium
3
+ module WebDriver
4
+ module Error
5
+ class NoSuchElementError < StandardError; end
6
+ end
7
+ end
8
+ end
9
+ end
10
+
11
+ module AdbDriver
12
+ module Error
13
+ class NoSuchElementError < Selenium::WebDriver::Error::NoSuchElementError; end
14
+ class TimeOutError < StandardError; end
15
+ end
16
+ end
@@ -0,0 +1,61 @@
1
+ module AdbDriver
2
+ module Finder
3
+ include Wait
4
+
5
+ FIND_ELEMENT_TIMEOUT = 1
6
+ PAGE_SOURCE_TIMEOUT = 15
7
+ MIN_TIME_BETWEEN_FIND_ATTEMPTS = 0.250
8
+
9
+ def find_element(locator)
10
+ wait(FIND_ELEMENT_TIMEOUT) { find_elements(locator).first }
11
+ rescue Wait::Error
12
+ logger.info "Element with locator '#{locator}' has not been found"
13
+ logger.debug "Current hierarchy: #{@last_view_hierarchy}"
14
+ raise Error::NoSuchElementError
15
+ end
16
+
17
+ def find_elements(locator)
18
+ logger.info "Searching for element by: #{locator}"
19
+
20
+ locator_type = locator.first.first
21
+ locator_value = locator.first.last
22
+
23
+ case locator_type
24
+ when :xpath then find_elements_by_xpath(locator_value)
25
+ when :id then find_elements_by_xpath("//*[contains(@resource-id,'#{locator_value}')]")
26
+ when :class_name then find_elements_by_xpath("//*[@class='#{locator_value}']")
27
+ when :content_desc then find_elements_by_xpath("//*[@content-desc='#{locator_value}']")
28
+ end
29
+ end
30
+
31
+ def find_elements_by_xpath(xpath)
32
+ sleep 0.05 until Time.now >= (@last_find_attempt_time ||= Time.now) + MIN_TIME_BETWEEN_FIND_ATTEMPTS
33
+
34
+ view_hierarchy = REXML::Document.new(page_source)
35
+ @last_view_hierarchy = view_hierarchy
36
+
37
+ result = view_hierarchy.get_elements(xpath).map { |element| Element.new(element) }
38
+ @last_find_attempt_time = Time.now
39
+ logger.debug "Returning element(s): #{result}"
40
+ result
41
+ end
42
+
43
+ def page_source
44
+ adb_command = Adb.android_7? ? 'exec-out uiautomator dump /dev/tty' : 'shell uiautomator dump /dev/tty'
45
+ logger.info "Getting view hierarchy"
46
+ result = Adb.execute_command(adb_command, PAGE_SOURCE_TIMEOUT)
47
+
48
+ if result.empty? || result.strip == 'Killed'
49
+ logger.info "Result is empty or 'Killed'. Restarting adb..."
50
+ Adb.restart_adb
51
+ logger.info "Adb restarted. Getting result..."
52
+ result = Adb.execute_command(adb_command, PAGE_SOURCE_TIMEOUT)
53
+ end
54
+
55
+ logger.debug "Result received: #{result}"
56
+ result
57
+ rescue Wait::Error
58
+ raise TimeOutError, "Couldn't get page_source in reasonable time (#{PAGE_SOURCE_TIMEOUT} seconds)"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ module AdbDriver
2
+ class Navigation
3
+ def back
4
+ `adb shell input keyevent KEYCODE_BACK`
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,60 @@
1
+ require 'rexml/document'
2
+ require 'rexml/sax2listener'
3
+ require 'rexml/parsers/sax2parser'
4
+
5
+ module SourceCleaner
6
+ def self.call(page_source)
7
+ p = REXML::Parsers::SAX2Parser.new(page_source)
8
+ p.listen(MrProper.new)
9
+ p.parse
10
+ end
11
+
12
+ class MrProper
13
+ include REXML::SAX2Listener
14
+
15
+ BORING_ATTRS = %w(class index package
16
+ checkable checked clickable
17
+ enabled focusable focused
18
+ scrollable long-clickable password
19
+ selected bounds instance)
20
+
21
+ INDENT_WIDTH = 2
22
+ ATTRIBUTES_INDENT = 4
23
+
24
+ def initialize
25
+ @indent = 0
26
+ end
27
+
28
+ def start_element(uri, localname, qname, attributes)
29
+ return if localname == 'hierarchy'
30
+
31
+ @indent += 1
32
+
33
+ node = ' ' * INDENT_WIDTH * @indent
34
+ node << "class_name: '#{attributes['class']}'"
35
+
36
+ clean_attributes = attributes.delete_if { |k, v| BORING_ATTRS.include?(k) || v.empty? }
37
+
38
+ if value = clean_attributes.delete('resource-id')
39
+ clean_attributes[:id] = value
40
+ end
41
+
42
+ if value = clean_attributes.delete('content-desc')
43
+ clean_attributes[:content_desc] = value
44
+ end
45
+
46
+ unless clean_attributes.empty?
47
+ node << ' ' * ATTRIBUTES_INDENT
48
+ node << clean_attributes.map { |k, v| "#{k}: '#{v}'" }.join(', ')
49
+ end
50
+
51
+ puts node
52
+ end
53
+
54
+ def end_element(uri, localname, qname)
55
+ @indent -= 1
56
+ end
57
+ end
58
+ end
59
+
60
+ SourceCleaner.call(ARGF.read) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,24 @@
1
+ module Wait
2
+ Error = Class.new(StandardError)
3
+
4
+ def self.included(mod)
5
+ mod.extend self
6
+ end
7
+
8
+ def wait(duration = 10, message = nil, &block)
9
+ polling_interaval = 0.1 # 100 msec
10
+ start = Time.now
11
+
12
+ loop do
13
+ sleep polling_interaval
14
+
15
+ result = block.call
16
+ break result if result
17
+
18
+ next if Time.now < start + duration
19
+
20
+ cleaned_stacktrace = caller.reverse.take_while { |line| line !~ /#{__FILE__}.*#{__method__}/ }.reverse
21
+ raise Error, (message || 'Wait::Error'), cleaned_stacktrace
22
+ end
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: adb_driver
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Anton Petrov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-05-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A driver with Seleium-like interface to interact with Android devices
14
+ directly via ADB
15
+ email: givemeletter@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/adb_driver.rb
21
+ - lib/adb_driver/adb.rb
22
+ - lib/adb_driver/driver.rb
23
+ - lib/adb_driver/dsl.rb
24
+ - lib/adb_driver/element.rb
25
+ - lib/adb_driver/error.rb
26
+ - lib/adb_driver/finder.rb
27
+ - lib/adb_driver/navigation.rb
28
+ - lib/adb_driver/source_cleaner.rb
29
+ - lib/adb_driver/wait.rb
30
+ homepage: https://github.com/sofaking/adb_driver
31
+ licenses:
32
+ - MIT
33
+ metadata: {}
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubyforge_project:
50
+ rubygems_version: 2.7.4
51
+ signing_key:
52
+ specification_version: 4
53
+ summary: A driver with Seleium-like interface to interact with Android devices directly
54
+ via ADB
55
+ test_files: []