require 'fastlane_core/print_table' require 'fastlane_core/command_executor' require 'fastlane/helper/adb_helper' require_relative 'reports_generator' require_relative 'module' module Screengrab class Runner attr_accessor :number_of_retries def initialize(executor = FastlaneCore::CommandExecutor, config = Screengrab.config, android_env = Screengrab.android_environment) @executor = executor @config = config @android_env = android_env end 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 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 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 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 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 def screenshot_file_names_in(output_directory, device_type) Dir.glob(File.join(output_directory, '**', device_type, '*.png'), File::FNM_CASEFOLD) end 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 # Don't need to use to use run-as if external 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 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 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 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 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 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 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 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 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 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 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 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 # 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. 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 # Return an array of packages that are installed on the device 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 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 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 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 end end