class Screengrab::Runner

Attributes

number_of_retries[RW]

Public Class Methods

new(executor = FastlaneCore::CommandExecutor, config = Screengrab.config, android_env = Screengrab.android_environment) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 11
def initialize(executor = FastlaneCore::CommandExecutor,
               config = Screengrab.config,
               android_env = Screengrab.android_environment)

  @executor = executor
  @config = config
  @android_env = android_env
end

Public Instance Methods

clear_device_previous_screenshots(app_package_name, device_serial, device_screenshots_paths) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 168
def clear_device_previous_screenshots(app_package_name, device_serial, device_screenshots_paths)
  UI.message('Cleaning screenshots on device')

  device_screenshots_paths.each do |(device_path, needs_run_as)|
    if_device_path_exists(app_package_name, device_serial, device_path, needs_run_as) do |path|
      # Determine if path needs the run-as permission
      run_as = needs_run_as ? " run-as #{app_package_name.shellescape.shellescape}" : ""

      run_adb_command("-s #{device_serial.shellescape} shell#{run_as} rm -rf #{path.shellescape.shellescape}",
                      print_all: true,
                      print_command: true)
    end
  end
end
clear_local_previous_screenshots(device_type_dir_name) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 110
def clear_local_previous_screenshots(device_type_dir_name)
  if @config[:clear_previous_screenshots]
    UI.message("Clearing #{device_type_dir_name} within #{@config[:output_directory]}")

    # We'll clear the temporary directory where screenshots wind up after being pulled from
    # the device as well, in case those got stranded on a previous run/failure
    ['screenshots', device_type_dir_name].each do |dir_name|
      files = screenshot_file_names_in(@config[:output_directory], dir_name)
      File.delete(*files)
    end
  end
end
determine_external_screenshots_path(device_serial, app_package_name, locales) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 143
def determine_external_screenshots_path(device_serial, app_package_name, locales)
  device_ext_storage = get_device_environment_variable(device_serial, "EXTERNAL_STORAGE")
  return locales.map do |locale|
    [
      File.join(device_ext_storage, app_package_name, 'screengrab', locale, "images", "screenshots"),
      File.join(device_ext_storage, "Android", "data", app_package_name, 'files', 'screengrab', locale, "images", "screenshots")
    ]
  end.flatten.map { |path| [path, false] }
end
determine_internal_screenshots_paths(device_serial, app_package_name, locales) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 153
def determine_internal_screenshots_paths(device_serial, app_package_name, locales)
  device_data = get_device_environment_variable(device_serial, "ANDROID_DATA")
  return locales.map do |locale|
    [
      "#{device_data}/user/0/#{app_package_name}/files/#{app_package_name}/screengrab/#{locale}/images/screenshots",

      # https://github.com/fastlane/fastlane/issues/15653#issuecomment-578541663
      "#{device_data}/data/#{app_package_name}/files/#{app_package_name}/screengrab/#{locale}/images/screenshots",

      "#{device_data}/data/#{app_package_name}/app_screengrab/#{locale}/images/screenshots",
      "#{device_data}/data/#{app_package_name}/screengrab/#{locale}/images/screenshots"
    ]
  end.flatten.map { |path| [path, true] }
end
device_api_version(device_serial) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 433
def device_api_version(device_serial)
  run_adb_command("-s #{device_serial.shellescape} shell getprop ro.build.version.sdk",
                  print_all: true, print_command: true).to_i
end
enable_clean_status_bar(device_serial, sdk_version) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 438
def enable_clean_status_bar(device_serial, sdk_version)
  return unless sdk_version >= 23

  UI.message('Enabling clean status bar')

  # Grant the DUMP permission
  run_adb_command("-s #{device_serial.shellescape} shell pm grant #{@config[:app_package_name].shellescape.shellescape} android.permission.DUMP",
                  print_all: true, print_command: true, raise_errors: false)

  # Enable the SystemUI demo mode
  run_adb_command("-s #{device_serial.shellescape} shell settings put global sysui_demo_allowed 1",
                  print_all: true, print_command: true)
end
get_device_environment_variable(device_serial, variable_name) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 127
def get_device_environment_variable(device_serial, variable_name)
  # macOS evaluates $foo in `echo $foo` before executing the command,
  # Windows doesn't - hence the double backslash vs. single backslash
  command = Helper.windows? ? "shell echo \$#{variable_name.shellescape.shellescape}" : "shell echo \\$#{variable_name.shellescape.shellescape}"
  value = run_adb_command("-s #{device_serial.shellescape} #{command}",
                          print_all: true,
                          print_command: true)
  return value.strip
end
grant_permissions(device_serial) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 215
def grant_permissions(device_serial)
  UI.message('Granting the permission necessary to change locales on the device')
  run_adb_command("-s #{device_serial.shellescape} shell pm grant #{@config[:app_package_name].shellescape.shellescape} android.permission.CHANGE_CONFIGURATION",
                  print_all: true,
                  print_command: true,
                  raise_errors: false)

  UI.message('Granting the permissions necessary to access device external storage')
  run_adb_command("-s #{device_serial.shellescape} shell pm grant #{@config[:app_package_name].shellescape.shellescape} android.permission.WRITE_EXTERNAL_STORAGE",
                  print_all: true,
                  print_command: true,
                  raise_errors: false)
  run_adb_command("-s #{device_serial.shellescape} shell pm grant #{@config[:app_package_name].shellescape.shellescape} android.permission.READ_EXTERNAL_STORAGE",
                  print_all: true,
                  print_command: true,
                  raise_errors: false)
end
if_device_path_exists(app_package_name, device_serial, device_path, needs_run_as) { |device_path| ... } click to toggle source

Some device commands fail if executed against a device path that does not exist, so this helper method provides a way to conditionally execute a block only if the provided path exists on the device.

# File screengrab/lib/screengrab/runner.rb, line 389
def if_device_path_exists(app_package_name, device_serial, device_path, needs_run_as)
  # Determine if path needs the run-as permission
  run_as = needs_run_as ? " run-as #{app_package_name.shellescape.shellescape}" : ""

  return if run_adb_command("-s #{device_serial.shellescape} shell#{run_as} ls #{device_path.shellescape.shellescape}",
                            print_all: false,
                            print_command: false).include?('No such file')

  yield(device_path)
rescue
  # Some versions of ADB will have a non-zero exit status for this, which will cause the executor to raise.
  # We can safely ignore that and treat it as if it returned 'No such file'
end
install_apks(device_serial, app_apk_path, tests_apk_path) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 183
def install_apks(device_serial, app_apk_path, tests_apk_path)
  UI.message('Installing app APK')
  apk_install_output = run_adb_command("-s #{device_serial.shellescape} install -t -r #{app_apk_path.shellescape}",
                                       print_all: true,
                                       print_command: true)
  UI.user_error!("App APK could not be installed") if apk_install_output.include?("Failure [")

  UI.message('Installing tests APK')
  apk_install_output = run_adb_command("-s #{device_serial.shellescape} install -t -r #{tests_apk_path.shellescape}",
                                       print_all: true,
                                       print_command: true)
  UI.user_error!("Tests APK could not be installed") if apk_install_output.include?("Failure [")
end
installed_packages(device_serial) click to toggle source

Return an array of packages that are installed on the device

# File screengrab/lib/screengrab/runner.rb, line 404
def installed_packages(device_serial)
  packages = run_adb_command("-s #{device_serial.shellescape} shell pm list packages",
                             print_all: true,
                             print_command: true)
  packages.split("\n").map { |package| package.gsub("package:", "") }
end
kill_app(device_serial, package_name) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 233
def kill_app(device_serial, package_name)
  run_adb_command("-s #{device_serial.shellescape} shell am force-stop #{package_name.shellescape.shellescape}.test",
                  print_all: true,
                  print_command: true)
  run_adb_command("-s #{device_serial.shellescape} shell am force-stop #{package_name.shellescape.shellescape}",
                  print_all: true,
                  print_command: true)
end
move_pulled_screenshots(locale, pull_dir, device_type_dir_name) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 355
def move_pulled_screenshots(locale, pull_dir, device_type_dir_name)
  # Glob pattern that finds the pulled screenshots directory for each locale
  # Possible matches:
  #   [pull_dir]/en-US/images/screenshots
  #   [pull_dir]/screengrab/en-US/images/screenshots
  screenshots_dir_pattern = File.join(pull_dir, '**', "screenshots")

  Dir.glob(screenshots_dir_pattern, File::FNM_CASEFOLD).each do |screenshots_dir|
    src_screenshots = Dir.glob(File.join(screenshots_dir, '*.png'), File::FNM_CASEFOLD)

    # The :output_directory is the final location for the screenshots, so we begin by replacing
    # the temp directory portion of the path, with the output directory
    dest_dir = screenshots_dir.gsub(pull_dir, @config[:output_directory])

    # Different versions of adb are inconsistent about whether they will pull down the containing
    # directory for the screenshots, so we'll try to remove that path from the directory name when
    # creating the destination path.
    # See: https://github.com/fastlane/fastlane/pull/4915#issuecomment-236368649
    dest_dir = dest_dir.gsub(%r{(app_)?screengrab/}, '')

    # We then replace the last segment of the screenshots directory path with the device_type
    # specific name, as expected by supply
    #
    # (Moved to: fastlane/metadata/android/en-US/images/phoneScreenshots)
    dest_dir = File.join(File.dirname(dest_dir), locale, 'images', device_type_dir_name)

    FileUtils.mkdir_p(dest_dir)
    FileUtils.cp_r(src_screenshots, dest_dir)
    UI.success("Screenshots copied to #{dest_dir}")
  end
end
pull_screenshots_from_device(locale, device_serial, device_type_dir_name) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 295
def pull_screenshots_from_device(locale, device_serial, device_type_dir_name)
  UI.message("Pulling captured screenshots for locale #{locale} from the device")
  starting_screenshot_count = screenshot_file_names_in(@config[:output_directory], device_type_dir_name).length

  UI.verbose("Starting screenshot count is: #{starting_screenshot_count}")

  device_screenshots_paths = [
    determine_external_screenshots_path(device_serial, @config[:app_package_name], [locale]),
    determine_internal_screenshots_paths(device_serial, @config[:app_package_name], [locale])
  ].flatten(1)

  # Make a temp directory into which to pull the screenshots before they are moved to their final location.
  # This makes directory cleanup easier, as the temp directory will be removed when the block completes.

  Dir.mktmpdir do |tempdir|
    device_screenshots_paths.each do |(device_path, needs_run_as)|
      if_device_path_exists(@config[:app_package_name], device_serial, device_path, needs_run_as) do |path|
        UI.message(path)
        next unless path.include?(locale)
        out = run_adb_command("-s #{device_serial.shellescape} pull #{path.shellescape} #{tempdir.shellescape}",
                              print_all: false,
                              print_command: true,
                              raise_errors: false)
        if out =~ /Permission denied/
          dir = File.dirname(path)
          base = File.basename(path)

          # Determine if path needs the run-as permission
          run_as = needs_run_as ? " run-as #{@config[:app_package_name].shellescape.shellescape}" : ""

          run_adb_command("-s #{device_serial.shellescape} shell#{run_as}  \"tar -cC #{dir} #{base}\" | tar -xv -f- -C #{tempdir}",
                          print_all: false,
                          print_command: true)
        end
      end
    end

    # The SDK can't 100% determine what kind of device it is running on relative to the categories that
    # supply and Google Play care about (phone, 7" tablet, TV, etc.).
    #
    # Therefore, we'll move the pulled screenshots from their genericly named folder to one named by the
    # user provided device_type option value to match the directory structure that supply expects
    move_pulled_screenshots(locale, tempdir, device_type_dir_name)
  end

  ending_screenshot_count = screenshot_file_names_in(@config[:output_directory], device_type_dir_name).length

  UI.verbose("Ending screenshot count is: #{ending_screenshot_count}")

  # Because we can't guarantee the screenshot output directory will be empty when we pull, we determine
  # success based on whether there are more screenshots there than when we started.
  # This is only applicable though when `clear_previous_screenshots` is set to `true`.
  if starting_screenshot_count == ending_screenshot_count && @config[:clear_previous_screenshots]
    UI.error("Make sure you've used Screengrab.screenshot() in your tests and that your expected tests are being run.")
    UI.abort_with_message!("No screenshots were detected 📷❌")
  end

  ending_screenshot_count - starting_screenshot_count
end
run() click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 20
def run
  # Standardize the locales
  FastlaneCore::PrintTable.print_values(config: @config, hide_keys: [], title: "Summary for screengrab #{Fastlane::VERSION}")

  app_apk_path = @config.fetch(:app_apk_path, ask: false)
  tests_apk_path = @config.fetch(:tests_apk_path, ask: false)
  discovered_apk_paths = Dir[File.join('**', '*.apk')]

  apk_paths_provided = app_apk_path && !app_apk_path.empty? && tests_apk_path && !tests_apk_path.empty?

  unless apk_paths_provided || discovered_apk_paths.any?
    UI.error('No APK paths were provided and no APKs could be found')
    UI.error("Please provide APK paths with 'app_apk_path' and 'tests_apk_path' and make sure you have assembled APKs prior to running this command.")
    return
  end

  test_classes_to_use = @config[:use_tests_in_classes]
  test_packages_to_use = @config[:use_tests_in_packages]

  if test_classes_to_use && test_classes_to_use.any? && test_packages_to_use && test_packages_to_use.any?
    UI.error("'use_tests_in_classes' and 'use_tests_in_packages' can not be combined. Please use one or the other.")
    return
  end

  if (test_classes_to_use.nil? || test_classes_to_use.empty?) && (test_packages_to_use.nil? || test_packages_to_use.empty?)
    UI.important('Limiting the test classes run by `screengrab` to just those that generate screenshots can make runs faster.')
    UI.important('Consider using the :use_tests_in_classes or :use_tests_in_packages option, and organize your tests accordingly.')
  end

  device_type_dir_name = "#{@config[:device_type]}Screenshots"
  clear_local_previous_screenshots(device_type_dir_name)

  device_serial = select_device

  device_screenshots_paths = [
    determine_external_screenshots_path(device_serial, @config[:app_package_name], @config[:locales]),
    determine_internal_screenshots_paths(device_serial, @config[:app_package_name], @config[:locales])
  ].flatten(1)

  # Root is needed to access device paths at /data
  if @config[:use_adb_root]
    run_adb_command("-s #{device_serial.shellescape} root", print_all: false, print_command: true)
    run_adb_command("-s #{device_serial.shellescape} wait-for-device", print_all: false, print_command: true)
  end

  clear_device_previous_screenshots(@config[:app_package_name], device_serial, device_screenshots_paths)

  app_apk_path ||= select_app_apk(discovered_apk_paths)
  tests_apk_path ||= select_tests_apk(discovered_apk_paths)

  number_of_screenshots = run_tests(device_type_dir_name, device_serial, app_apk_path, tests_apk_path, test_classes_to_use, test_packages_to_use, @config[:launch_arguments])

  ReportsGenerator.new.generate

  UI.success("Captured #{number_of_screenshots} new screenshots! 📷✨")
end
run_adb_command(command, print_all: false, print_command: false, raise_errors: true) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 411
def run_adb_command(command, print_all: false, print_command: false, raise_errors: true)
  adb_host = @config[:adb_host]
  host = adb_host.nil? ? '' : "-H #{adb_host.shellescape} "
  output = ''
  begin
    errout = nil
    cmdout = @executor.execute(command: @android_env.adb_path + " " + host + command,
                              print_all: print_all,
                              print_command: print_command,
                              error: raise_errors ? nil : proc { |out, status| errout = out }) || ''
    output = errout || cmdout
  rescue => ex
    if raise_errors
      raise ex
    end
  end
  output.lines.reject do |line|
    # Debug/Warning output from ADB
    line.start_with?('adb: ') && !line.start_with?('adb: error: ')
  end.join('') # Lines retain their newline chars
end
run_tests(device_type_dir_name, device_serial, app_apk_path, tests_apk_path, test_classes_to_use, test_packages_to_use, launch_arguments) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 242
def run_tests(device_type_dir_name, device_serial, app_apk_path, tests_apk_path, test_classes_to_use, test_packages_to_use, launch_arguments)
  sdk_version = device_api_version(device_serial)
  unless @config[:reinstall_app]
    install_apks(device_serial, app_apk_path, tests_apk_path)
    grant_permissions(device_serial)
    enable_clean_status_bar(device_serial, sdk_version)
  end

  number_of_screenshots = 0

  @config[:locales].each do |locale|
    if @config[:reinstall_app]
      uninstall_apks(device_serial, @config[:app_package_name], @config[:tests_package_name])
      install_apks(device_serial, app_apk_path, tests_apk_path)
      grant_permissions(device_serial)
    else
      kill_app(device_serial, @config[:app_package_name])
    end
    number_of_screenshots += run_tests_for_locale(device_type_dir_name, locale, device_serial, test_classes_to_use, test_packages_to_use, launch_arguments, sdk_version)
  end

  number_of_screenshots
end
run_tests_for_locale(device_type_dir_name, locale, device_serial, test_classes_to_use, test_packages_to_use, launch_arguments, sdk_version) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 266
def run_tests_for_locale(device_type_dir_name, locale, device_serial, test_classes_to_use, test_packages_to_use, launch_arguments, sdk_version)
  UI.message("Running tests for locale: #{locale}")

  instrument_command = ["-s #{device_serial.shellescape} shell am instrument --no-window-animation -w",
                        "-e testLocale #{locale.shellescape.shellescape}"]
  if sdk_version >= 28
    instrument_command << "--no-hidden-api-checks"
  end
  instrument_command << "-e appendTimestamp #{@config[:use_timestamp_suffix]}"
  instrument_command << "-e class #{test_classes_to_use.join(',').shellescape.shellescape}" if test_classes_to_use
  instrument_command << "-e package #{test_packages_to_use.join(',').shellescape.shellescape}" if test_packages_to_use
  instrument_command << launch_arguments.map { |item| '-e ' + item }.join(' ') if launch_arguments
  instrument_command << "#{@config[:tests_package_name].shellescape.shellescape}/#{@config[:test_instrumentation_runner].shellescape.shellescape}"

  test_output = run_adb_command(instrument_command.join(" \\\n"),
                                print_all: true,
                                print_command: true)

  if test_output.include?("FAILURES!!!")
    if @config[:exit_on_test_failure]
      UI.test_failure!("Tests failed for locale #{locale} on device #{device_serial}")
    else
      UI.error("Tests failed")
    end
  end

  pull_screenshots_from_device(locale, device_serial, device_type_dir_name)
end
screenshot_file_names_in(output_directory, device_type) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 123
def screenshot_file_names_in(output_directory, device_type)
  Dir.glob(File.join(output_directory, '**', device_type, '*.png'), File::FNM_CASEFOLD)
end
select_app_apk(discovered_apk_paths) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 100
def select_app_apk(discovered_apk_paths)
  UI.important("To not be asked about this value, you can specify it using 'app_apk_path'")
  UI.select('Select your debug app APK', discovered_apk_paths)
end
select_device() click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 77
def select_device
  adb = Fastlane::Helper::AdbHelper.new(adb_path: @android_env.adb_path, adb_host: @config[:adb_host])
  devices = adb.load_all_devices

  UI.user_error!('There are no connected and authorized devices or emulators') if devices.empty?

  specific_device = @config[:specific_device]
  if specific_device
    devices.select! do |d|
      d.serial.include?(specific_device)
    end
  end

  UI.user_error!("No connected devices matched your criteria: #{specific_device}") if devices.empty?

  if devices.length > 1
    UI.important("Multiple connected devices, selecting the first one")
    UI.important("To specify which connected device to use, use the -s (specific_device) config option")
  end

  devices.first.serial
end
select_tests_apk(discovered_apk_paths) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 105
def select_tests_apk(discovered_apk_paths)
  UI.important("To not be asked about this value, you can specify it using 'tests_apk_path'")
  UI.select('Select your debug tests APK', discovered_apk_paths)
end
uninstall_apks(device_serial, app_package_name, tests_package_name) click to toggle source
# File screengrab/lib/screengrab/runner.rb, line 197
def uninstall_apks(device_serial, app_package_name, tests_package_name)
  packages = installed_packages(device_serial)

  if packages.include?(app_package_name.to_s)
    UI.message('Uninstalling app APK')
    run_adb_command("-s #{device_serial.shellescape} uninstall #{app_package_name.shellescape}",
                    print_all: true,
                    print_command: true)
  end

  if packages.include?(tests_package_name.to_s)
    UI.message('Uninstalling tests APK')
    run_adb_command("-s #{device_serial.shellescape} uninstall #{tests_package_name.shellescape}",
                    print_all: true,
                    print_command: true)
  end
end
use_adb_run_as?(path, device_serial) click to toggle source

Don't need to use to use run-as if external

# File screengrab/lib/screengrab/runner.rb, line 138
def use_adb_run_as?(path, device_serial)
  device_ext_storage = get_device_environment_variable(device_serial, "EXTERNAL_STORAGE")
  return !path.start_with?(device_ext_storage)
end