diff --git a/.rubocop.yml b/.rubocop.yml index d4ce03af1..d4823cf94 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -67,7 +67,7 @@ Metrics/ClassLength: Max: 300 Metrics/MethodLength: - Max: 100 + Max: 150 Metrics/ModuleLength: Max: 300 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6a1ff51..148500dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### New Features * Introduce new `ios_send_app_size_metrics` and `android_send_app_size_metrics` actions. [#364] [#365] +* Add the ability to run Firebase Test Lab tests. [#355] ### Bug Fixes diff --git a/Gemfile.lock b/Gemfile.lock index a8265d3cc..828bb6ec9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,6 +8,7 @@ PATH chroma (= 0.2.0) diffy (~> 3.3) git (~> 1.3) + google-cloud-storage (~> 1.31) jsonlint (~> 0.3) nokogiri (~> 1.11) octokit (~> 4.18) diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index 92d1dd2f5..8b784e8b5 100644 --- a/fastlane-plugin-wpmreleasetoolkit.gemspec +++ b/fastlane-plugin-wpmreleasetoolkit.gemspec @@ -38,6 +38,10 @@ Gem::Specification.new do |spec| spec.add_dependency 'progress_bar', '~> 1.3' spec.add_dependency 'rake', '>= 12.3', '< 14.0' spec.add_dependency 'rake-compiler', '~> 1.0' + + # `google-cloud-storage` is required by fastlane, but we pin it in case it's not in the future + spec.add_dependency 'google-cloud-storage', '~> 1.31' + # Some of the upstream code uses `BigDecimal.new` which version 2.0 of the # `bigdecimal` gem removed. Until we'll find the time to identify the # dependencies and see if we can move them to something compatible with diff --git a/lib/fastlane/plugin/wpmreleasetoolkit.rb b/lib/fastlane/plugin/wpmreleasetoolkit.rb index dad7c7064..d63e75b54 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit.rb @@ -2,9 +2,9 @@ module Fastlane module Wpmreleasetoolkit - # Return all .rb files inside the "actions" and "helper" directory + # Return all .rb files inside the "actions", "helper" and "models" directories def self.all_classes - Dir[File.expand_path('**/{actions,helper}/**/*.rb', File.dirname(__FILE__))] + Dir[File.expand_path('**/{actions,helper,models}/**/*.rb', File.dirname(__FILE__))] end end end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_firebase_test.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_firebase_test.rb new file mode 100644 index 000000000..07484d64a --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_firebase_test.rb @@ -0,0 +1,187 @@ +require 'securerandom' + +module Fastlane + module Actions + module SharedValues + FIREBASE_TEST_RESULT = :FIREBASE_TEST_LOG_FILE + FIREBASE_TEST_LOG_FILE_PATH = :FIREBASE_TEST_LOG_FILE_PATH + end + + class AndroidFirebaseTestAction < Action + def self.run(params) + validate_options(params) + + UI.user_error!('You must be logged in to Firebase prior to calling this action. Use the `FirebaseLogin` Action to log in if needed') unless Fastlane::FirebaseAccount.authenticated? + + # Log in to Firebase (and validate credentials) + run_uuid = params[:test_run_id] || SecureRandom.uuid + test_dir = params[:results_output_dir] || File.join(Dir.tmpdir(), run_uuid) + + # Set up the log file and output directory + FileUtils.mkdir_p(test_dir) + Fastlane::Actions.lane_context[:FIREBASE_TEST_LOG_FILE_PATH] = File.join(test_dir, 'output.log') + + device = Fastlane::FirebaseDevice.new( + model: params[:model], + version: params[:version], + locale: params[:locale], + orientation: params[:orientation] + ) + + result = FirebaseTestRunner.run_tests( + project_id: params[:project_id], + apk_path: params[:apk_path], + test_apk_path: params[:test_apk_path], + device: device, + type: params[:type] + ) + + # Download all of the outputs from the job to the local machine + FirebaseTestRunner.download_result_files( + result: result, + destination: test_dir, + project_id: params[:project_id], + key_file_path: params[:key_file] + ) + + FastlaneCore::UI.test_failure! "Firebase Tests failed – more information can be found at #{result.more_details_url}" unless result.success? + + UI.success 'Firebase Tests Complete' + end + + # Fastlane doesn't eagerly validate options for us, so we'll do it first to have control over + # when they're evalutated. + def self.validate_options(params) + available_options + .reject { |opt| opt.optional || !opt.default_value.nil? } + .map(&:key) + .each { |k| params[k] } + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Runs the specified tests in Firebase Test Lab' + end + + def self.details + description + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :project_id, + # `env_name` comes from the Google Cloud default: https://cloud.google.com/functions/docs/configuring/env-var + env_name: 'GCP_PROJECT', + description: 'The Project ID to test in', + type: String + ), + FastlaneCore::ConfigItem.new( + key: :key_file, + description: 'The key file used to authorize with Google Cloud', + type: String, + verify_block: proc do |value| + UI.user_error!('The `:key_file` parameter is required') if value.empty? + UI.user_error!("No Google Cloud Key file found at: #{value}") unless File.exist?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :apk_path, + description: 'Path to the application APK on the local machine', + type: String, + verify_block: proc do |value| + UI.user_error!('The `:apk_path` parameter is required') if value.empty? + UI.user_error!("Invalid application APK: #{value}") unless File.exist?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :test_apk_path, + description: 'Path to the test bundle APK on the local machine', + type: String, + verify_block: proc do |value| + UI.user_error!('The `:test_apk_path` parameter is required') if value.empty? + UI.user_error!("Invalid test APK: #{value}") unless File.exist?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :model, + description: 'The device model to use to run the test', + type: String, + verify_block: proc do |value| + UI.user_error!('The `:model` parameter is required') if value.empty? + FirebaseTestRunner.verify_has_gcloud_binary! + model_names = Fastlane::FirebaseDevice.valid_model_names + UI.user_error!("Invalid Model Name: #{value}. Valid Model Names: #{model_names}") unless model_names.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :version, + description: 'The Android version (API Level) to use to run the test', + type: Integer, + verify_block: proc do |value| + FirebaseTestRunner.verify_has_gcloud_binary! + version_numbers = Fastlane::FirebaseDevice.valid_version_numbers + UI.user_error!("Invalid Version Number: #{value}. Valid Version Numbers: #{version_numbers}") unless version_numbers.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :locale, + description: 'The locale code to use when running the test', + type: String, + default_value: 'en', + verify_block: proc do |value| + FirebaseTestRunner.verify_has_gcloud_binary! + locale_codes = Fastlane::FirebaseDevice.valid_locales + UI.user_error!("Invalid Locale: #{value}. Valid Locales: #{locale_codes}") unless locale_codes.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :orientation, + description: 'Which orientation to run the device in', + type: String, + default_value: 'portrait', + verify_block: proc do |value| + orientations = Fastlane::FirebaseDevice.valid_orientations + UI.user_error!("Invalid Orientation: #{value}. Valid Orientations: #{orientations}") unless orientations.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :type, + description: 'The type of test to run (e.g. `instrumentation` or `robo`)', + type: String, + default_value: 'instrumentation', + verify_block: proc do |value| + types = Fastlane::FirebaseTestRunner::VALID_TEST_TYPES + UI.user_error!("Invalid Test Type: #{value}. Valid Types: #{types}") unless types.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :test_run_id, + description: 'A unique ID used to identify this test run', + default_value_dynamic: true, + optional: true, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :results_output_dir, + description: 'The path to the folder where we will store the results of this test run', + default_value_dynamic: true, + optional: true, + type: String + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + platform == :android + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_send_app_size_metrics.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_send_app_size_metrics.rb index 39194f1fd..c4faa3d3d 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_send_app_size_metrics.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_send_app_size_metrics.rb @@ -152,7 +152,6 @@ def self.details DETAILS end - # rubocop:disable Metrics/MethodLength def self.available_options [ FastlaneCore::ConfigItem.new( @@ -259,7 +258,6 @@ def self.available_options ), ] end - # rubocop:enable Metrics/MethodLength def self.return_type :integer diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/firebase_login.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/firebase_login.rb new file mode 100644 index 000000000..47d87b945 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/firebase_login.rb @@ -0,0 +1,44 @@ +require 'securerandom' + +module Fastlane + module Actions + class FirebaseLoginAction < Action + def self.run(params) + Fastlane::FirebaseAccount.activate_service_account_with_key_file(params[:key_file]) + end + + ##################################################### + # @!group Documentation + ##################################################### + def self.description + 'Logs the local machine into Google Cloud using the provided key file' + end + + def self.details + description + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :key_file, + description: 'The key file used to authorize with Google Cloud', + type: String, + verify_block: proc do |value| + UI.user_error!('The `:key_file` parameter is required') if value.empty? + UI.user_error!("No Google Cloud Key file found at: #{value}") unless File.exist?(value) + end + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_account.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_account.rb new file mode 100644 index 000000000..df3844605 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_account.rb @@ -0,0 +1,19 @@ +module Fastlane + class FirebaseAccount + def self.activate_service_account_with_key_file(key_file_path) + Fastlane::Actions.sh('gcloud', 'auth', 'activate-service-account', '--key-file', key_file_path) + end + + def self.authenticated? + auth_status = JSON.parse(auth_status_data) + auth_status.any? do |account| + account['status'] == 'ACTIVE' + end + end + + # Lookup the current account authentication status + def self.auth_status_data + Fastlane::Actions.sh('gcloud', 'auth', 'list', '--format', 'json', log: false) + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_device.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_device.rb new file mode 100644 index 000000000..ef8cb02fc --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_device.rb @@ -0,0 +1,62 @@ +module Fastlane + class FirebaseDevice + attr_reader :model, :version, :locale, :orientation + + def initialize(model:, version:, orientation:, locale: 'en') + raise 'Invalid Model' unless FirebaseDevice.valid_model_names.include? model + raise 'Invalid Version' unless FirebaseDevice.valid_version_numbers.include? version + raise 'Invalid Locale' unless FirebaseDevice.valid_locales.include? locale + raise 'Invalid Orientation' unless FirebaseDevice.valid_orientations.include? orientation + + @model = model + @version = version + @locale = locale + @orientation = orientation + end + + def to_s + "model=#{@model},version=#{@version},locale=#{@locale},orientation=#{@orientation}" + end + + class << self + @locale_data = nil + @model_data = nil + @version_data = nil + + def valid_model_names + JSON.parse(model_data).map { |device| device['codename'] } + end + + def valid_version_numbers + JSON.parse(version_data).map { |version| version['apiLevel'].to_i } + end + + def valid_locales + JSON.parse(locale_data).map { |locale| locale['id'] } + end + + def valid_orientations + %w[portrait landscape] + end + + def locale_data + FirebaseDevice.verify_logged_in! + @locale_data ||= Fastlane::Actions.sh('gcloud', 'firebase', 'test', 'android', 'locales', 'list', '--format="json"', log: false) + end + + def model_data + FirebaseDevice.verify_logged_in! + @model_data ||= Fastlane::Actions.sh('gcloud', 'firebase', 'test', 'android', 'models', 'list', '--format="json"', log: false) + end + + def version_data + FirebaseDevice.verify_logged_in! + @version_data ||= Fastlane::Actions.sh('gcloud', 'firebase', 'test', 'android', 'versions', 'list', '--format="json"', log: false) + end + + def verify_logged_in! + UI.user_error!('You must call `firebase_login` before creating a FirebaseDevice object') unless FirebaseAccount.authenticated? + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_test_lab_result.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_test_lab_result.rb new file mode 100644 index 000000000..1a5b6460c --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_test_lab_result.rb @@ -0,0 +1,36 @@ +module Fastlane + class FirebaseTestLabResult + def initialize(log_file_path:) + raise "No log file found at path #{log_file_path}" unless File.file? log_file_path + + @path = log_file_path + end + + # Scan the log file to for indications that no test cases failed + def success? + File.readlines(@path).any? { |line| line.include?('Passed') && line.include?('test cases passed') } + end + + # Parse the log for the "More details are available..." URL + def more_details_url + File.readlines(@path) + .flat_map { |line| URI.extract(line) } + .find { |url| URI(url).host == 'console.firebase.google.com' && url.include?('/matrices/') } + end + + # Parse the log for the Google Cloud Storage Bucket URL + def raw_results_paths + uri = File.readlines(@path) + .flat_map { |line| URI.extract(line) } + .map { |string| URI(string) } + .find { |u| u.scheme == 'gs' } + + return nil if uri.nil? + + return { + bucket: uri.host, + prefix: uri.path.delete_prefix('/').chomp('/') + } + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_test_runner.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_test_runner.rb new file mode 100644 index 000000000..9f90c0d3d --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/firebase_test_runner.rb @@ -0,0 +1,104 @@ +require 'json' +require 'uri' +require 'fileutils' +require 'google/cloud/storage' + +module Fastlane + class FirebaseTestRunner + VALID_TEST_TYPES = %w[instrumentation robo].freeze + + def self.preflight(verify_gcloud_binary: true, verify_logged_in: true) + verify_has_gcloud_binary! if verify_gcloud_binary + verify_logged_in! if verify_logged_in + end + + # Run a given APK and Test Bundle on the given device type. + # + # @param [String] project_id The Google Firebase Console Project ID. + # @param [String] apk_path Path to the application APK on disk. + # @param [String] test_apk_path Path to the test runner APK on disk. + # @param [FirebaseDevice] device The virtual device to run tests on. + # @param [String] type The type of test to run. + # + def self.run_tests(project_id:, apk_path:, test_apk_path:, device:, type: 'instrumentation') + raise "Unable to find apk: #{apk_path}" unless File.file?(apk_path) + raise "Unable to find apk: #{test_apk_path}" unless File.file?(test_apk_path) + raise "Invalid Type: #{type}" unless VALID_TEST_TYPES.include?(type) + + command = Shellwords.join [ + 'gcloud', 'firebase', 'test', 'android', 'run', + '--project', project_id, + '--type', type, + '--app', apk_path, + '--test', test_apk_path, + '--device', device.to_s, + '--verbosity', 'info', + ] + + log_file_path = Fastlane::Actions.lane_context[:FIREBASE_TEST_LOG_FILE_PATH] + + UI.message "Streaming log output to #{log_file_path}" + Action.sh("#{command} 2>&1 | tee #{log_file_path}") + + # Make the file object available to other tasks + result = FirebaseTestLabResult.new(log_file_path: log_file_path) + Fastlane::Actions.lane_context[:FIREBASE_TEST_LOG_FILE] = result + + result + end + + # Downloads all files associated with a Firebase Test Run to the local machine. + # + # @param [FirebaseTestLabResult] result The result bundle for a given test run. + # @param [String] destination The local directory to store all downloaded files. + # @param [String] project_id The Google Cloud Project ID – required for Google Cloud Storage access. + # @param [String] key_file_path The path to the key file – required for Google Cloud Storage access. + # + def self.download_result_files(result:, destination:, project_id:, key_file_path:) + UI.user_error! 'You must pass a `FirebaseTestLabResult` to this method' unless result.is_a? Fastlane::FirebaseTestLabResult + + paths = result.raw_results_paths + UI.user_error! "Log File doesn't contain a raw results URL" if paths.nil? + + FileUtils.mkdir_p(destination) unless File.directory?(destination) + + storage = Google::Cloud::Storage.new( + project_id: project_id, + credentials: key_file_path + ) + + # Set up the download + bucket = storage.bucket(paths[:bucket]) + files_to_download = bucket.files(prefix: paths[:prefix]) + + # Download the files + UI.header "Downloading Results Files to #{destination}" + files_to_download.each { |file| download_file(file: file, destination: destination) } + end + + # Download a Google Cloud Storage file to the local machine, creating intermediate directories as needed. + # + # @param [Google::Cloud::Storage::File] file Usually provided via `bucket.files`. + # @param [String] destination The local directory to store the file. It will retain its original name. + # + def self.download_file(file:, destination:) + destination = File.join(destination, file.name) + FileUtils.mkdir_p(File.dirname(destination)) + + # Print our progress + UI.message(file.name) + + file.download(destination) + end + + def self.verify_has_gcloud_binary! + Action.sh('command', '-v', 'gcloud', print_command: false, print_command_output: false) + rescue StandardError + UI.user_error!("The `gcloud` binary isn't available on this machine. Unable to continue.") + end + + def self.verify_logged_in! + UI.user_error!('You are not logged into Firebase on this machine. Unable to continue.') unless FirebaseAccount.authenticated? + end + end +end diff --git a/spec/android_firebase_test_spec.rb b/spec/android_firebase_test_spec.rb new file mode 100644 index 000000000..8008a0a63 --- /dev/null +++ b/spec/android_firebase_test_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe Fastlane::Actions::AndroidFirebaseTestAction do + let(:locale_sample_data) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-locale-list.json')) } + let(:model_sample_data) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-model-list.json')) } + let(:version_sample_data) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-version-list.json')) } + + before do |test| + next if test.metadata[:calls_data_providers] + + allow(Fastlane::FirebaseDevice).to receive(:locale_data).and_return(locale_sample_data) + allow(Fastlane::FirebaseDevice).to receive(:model_data).and_return(model_sample_data) + allow(Fastlane::FirebaseDevice).to receive(:version_data).and_return(version_sample_data) + + # Some development environments may have this set + ENV['GCP_PROJECT'] = nil + end + + describe 'Calling the Action validates input' do + it 'raises for missing `project_id`' do + expect { run_action_without_key(:project_id) }.to raise_error "No value found for 'project_id'" + end + + it 'raises for missing `key_file` parameter' do + expect { run_action_without_key(:key_file) }.to raise_error 'The `:key_file` parameter is required' + end + + it 'raises for invalid `key_file` parameter' do + expect { run_action_with(:key_file, 'foo') }.to raise_error 'No Google Cloud Key file found at: foo' + end + + it 'raises for missing `apk_path` parameter' do + expect { run_action_without_key(:apk_path) }.to raise_error 'The `:apk_path` parameter is required' + end + + it 'raises for invalid `apk_path` parameter' do + expect { run_action_with(:apk_path, 'foo') }.to raise_error 'Invalid application APK: foo' + end + + it 'raises for missing `test_apk_path` parameter' do + expect { run_action_without_key(:test_apk_path) }.to raise_error 'The `:test_apk_path` parameter is required' + end + + it 'raises for invalid `test_apk_path` parameter' do + expect { run_action_with(:test_apk_path, 'foo') }.to raise_error 'Invalid test APK: foo' + end + + it 'raises for missing `model` parameter' do + expect { run_action_without_key(:model) }.to raise_error 'The `:model` parameter is required' + end + + it 'raises for invalid `model` parameter' do + expect { run_action_with(:model, 'foo') }.to raise_error(/Invalid Model Name: foo/) + end + + # This doesn't work because of a `fastlane` bug – everything becomes a string, even a missing parameter + # it 'raises for missing `version` parameter' do + # expect{ run_action_without_key(:version) }.to raise_error "You must specify the `:version` parameter." + # end + + it 'raises for string `version` parameter' do + expect { run_action_with(:version, 'foo') }.to raise_error "'version' value must be a Integer! Found String instead." + end + + it 'raises for out-of-range `version` parameter' do + expect { run_action_with(:version, 99) }.to raise_error 'Invalid Version Number: 99. Valid Version Numbers: [18, 19, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]' + end + + it 'raises for invalid `orientation` parameter' do + expect { run_action_with(:orientation, 'foo') }.to raise_error 'Invalid Orientation: foo. Valid Orientations: ["portrait", "landscape"]' + end + + it 'raises for invalid `type` parameter' do + expect { run_action_with(:type, 'foo') }.to raise_error 'Invalid Test Type: foo. Valid Types: ["instrumentation", "robo"]' + end + end + + describe 'Logging' do + it 'raises when run without being logged into gcloud' do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(false) + expect { run_described_fastlane_action(defaults) }.to raise_error 'You must be logged in to Firebase prior to calling this action. Use the `FirebaseLogin` Action to log in if needed' + end + + it 'raises on test failure' do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(true) + allow(Fastlane::FirebaseTestRunner).to receive(:run_tests).and_return(failed_result) + allow(Fastlane::FirebaseTestRunner).to receive(:download_result_files) + + expect { run_described_fastlane_action(defaults) }.to raise_error(/Firebase Tests failed – more information can be found at /) + end + + it 'prints success message on completion' do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(true) + allow(Fastlane::FirebaseTestRunner).to receive(:run_tests).and_return(success_result) + allow(Fastlane::FirebaseTestRunner).to receive(:download_result_files) + allow(Fastlane::UI).to receive('success').with('Firebase Tests Complete') + end + + def success_result + path = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-passed.log') + Fastlane::FirebaseTestLabResult.new(log_file_path: path) + end + + def failed_result + path = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-failure.log') + Fastlane::FirebaseTestLabResult.new(log_file_path: path) + end + end + + def run_action_without_key(key) + run_described_fastlane_action(defaults.except(key)) + end + + def run_action_with(key, value) + values = defaults + values[key] = value + run_described_fastlane_action(values) + end + + def defaults + { + project_id: '1234', + key_file: __FILE__, + apk_path: __FILE__, + test_apk_path: __FILE__, + model: 'Nexus5', + version: 31 + } + end +end diff --git a/spec/firebase_account_spec.rb b/spec/firebase_account_spec.rb new file mode 100644 index 000000000..03f30fcc1 --- /dev/null +++ b/spec/firebase_account_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Fastlane::FirebaseAccount do + let(:logged_out_status) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-empty-user-list.json')) } + let(:logged_in_status) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-authenticated-user-list.json')) } + + describe 'authenticated?' do + it 'correctly parses logged out status' do + allow(described_class).to receive(:auth_status_data).and_return(logged_out_status) + expect(described_class.authenticated?).to be false + end + + it 'correctly parses logged in status' do + allow(described_class).to receive(:auth_status_data).and_return(logged_in_status) + expect(described_class.authenticated?).to be true + end + end + + describe '#activate_service_account_with_key_file' do + it 'runs the right command' do + expect(Fastlane::Actions).to receive('sh').with('gcloud', 'auth', 'activate-service-account', '--key-file', 'foo') + described_class.activate_service_account_with_key_file('foo') + end + end + + describe '#auth_status_data' do + it 'runs the right command' do + expect(Fastlane::Actions).to receive('sh').with('gcloud', 'auth', 'list', '--format', 'json', log: false) + described_class.auth_status_data + end + end +end diff --git a/spec/firebase_device_spec.rb b/spec/firebase_device_spec.rb new file mode 100644 index 000000000..dd15f0625 --- /dev/null +++ b/spec/firebase_device_spec.rb @@ -0,0 +1,100 @@ +require 'spec_helper' + +describe Fastlane::FirebaseDevice do + let(:locale_sample_data) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-locale-list.json')) } + let(:model_sample_data) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-model-list.json')) } + let(:version_sample_data) { File.read(File.join(__dir__, 'test-data', 'firebase', 'firebase-version-list.json')) } + + before do |test| + next if test.metadata[:calls_data_providers] + + allow(described_class).to receive(:locale_data).and_return(locale_sample_data) + allow(described_class).to receive(:model_data).and_return(model_sample_data) + allow(described_class).to receive(:version_data).and_return(version_sample_data) + end + + def create_model(model: 'Nexus5', version: 27, locale: 'en', orientation: 'portrait') + described_class.new(model: model, version: version, locale: locale, orientation: orientation) + end + + describe 'initialization' do + it 'assigns ivars correctly' do + expect(create_model(model: 'Nexus5').model).to eq 'Nexus5' + expect(create_model(version: 27).version).to eq 27 + expect(create_model(locale: 'en').locale).to eq 'en' + expect(create_model(orientation: 'portrait').orientation).to eq 'portrait' + end + + it 'throws for invalid model name' do + expect { create_model(model: 'foo') }.to raise_exception('Invalid Model') + end + + it 'throws for invalid version code' do + expect { create_model(version: 99) }.to raise_exception('Invalid Version') + end + + it 'throws for invalid locale code' do + expect { create_model(locale: 'foo') }.to raise_exception('Invalid Locale') + end + end + + describe '#to_s' do + subject { create_model.to_s } + + it { is_expected.to eq 'model=Nexus5,version=27,locale=en,orientation=portrait' } + end + + describe '.valid_model_names' do + subject { described_class.valid_model_names } + + it { is_expected.to be_an_instance_of(Array) } + it { is_expected.to all(be_a(String)) } + end + + describe '.valid_locales' do + subject { described_class.valid_locales } + + it { is_expected.to be_an_instance_of(Array) } + it { is_expected.to all(be_a(String)) } + end + + describe '.valid_version_numbers' do + subject { described_class.valid_version_numbers } + + it { is_expected.to be_an_instance_of(Array) } + it { is_expected.to all(be_a(Integer)) } + end + + describe '.valid_orientations' do + subject { described_class.valid_orientations } + + it { is_expected.to be_an_instance_of(Array) } + it { is_expected.to all(be_a(String)) } + it { is_expected.to include 'portrait' } + it { is_expected.to include 'landscape' } + end + + describe '.locale_data' do + it 'runs the right command', :calls_data_providers do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(true) + expect(Fastlane::Actions).to receive('sh').with('gcloud', 'firebase', 'test', 'android', 'locales', 'list', '--format="json"', log: false) + described_class.locale_data + end + end + + describe '.model_data' do + it 'runs the right command', :calls_data_providers do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(true) + expect(Fastlane::Actions).to receive('sh').with('gcloud', 'firebase', 'test', 'android', 'models', 'list', '--format="json"', log: false) + described_class.model_data + end + end + + describe '.version_data' do + it 'runs the right command', :calls_data_providers do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(true) + expect(Fastlane::Actions).to receive('sh').with('gcloud', 'firebase', 'test', 'android', 'versions', 'list', '--format="json"', log: false) + described_class.version_data + end + end +end diff --git a/spec/firebase_login_spec.rb b/spec/firebase_login_spec.rb new file mode 100644 index 000000000..ea0cb5a38 --- /dev/null +++ b/spec/firebase_login_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Fastlane::Actions::FirebaseLoginAction do + describe 'Calling the Action Validates Input' do + it 'raises for missing `key_file` parameter' do + expect { run_described_fastlane_action({}) }.to raise_error 'The `:key_file` parameter is required' + end + + it 'raises for invalid `key_file` parameter' do + expect { run_described_fastlane_action({ key_file: 'foo' }) }.to raise_error 'No Google Cloud Key file found at: foo' + end + end +end diff --git a/spec/firebase_test_lab_result_spec.rb b/spec/firebase_test_lab_result_spec.rb new file mode 100644 index 000000000..46291bc22 --- /dev/null +++ b/spec/firebase_test_lab_result_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Fastlane::FirebaseTestLabResult do + let(:empty_test_log) { described_class.new(log_file_path: EMPTY_FIREBASE_TEST_LOG_PATH) } + let(:passed_test_log) { described_class.new(log_file_path: PASSED_FIREBASE_TEST_LOG_PATH) } + let(:failed_test_log) { described_class.new(log_file_path: FAILED_FIREBASE_TEST_LOG_PATH) } + + let(:invalid_test_log_path) { 'foo' } + + describe 'initialize' do + it 'raises for an invalid file path' do + expect { described_class.new(log_file_path: invalid_test_log_path) }.to raise_exception 'No log file found at path foo' + end + end + + describe 'success?' do + it 'returns true for success' do + expect(passed_test_log.success?).to be true + end + + it 'returns false for failure' do + expect(failed_test_log.success?).to be false + end + end + + describe 'more_details_url' do + it 'returns the "more details url"' do + expect(failed_test_log.more_details_url).to eq 'https://console.firebase.google.com/project/redacted/testlab/histories/bh.edfd947f2636efe3/matrices/4770383643393920434' + end + + it 'returns nil if not present' do + expect(empty_test_log.more_details_url).to be_nil + end + end + + describe 'raw_results_paths' do + it 'returns the bucket name for the raw results' do + expect(failed_test_log.raw_results_paths[:bucket]).to eq 'test-lab-wjdmcn8vd90jx-wfb9uburfx80m' + end + + it 'returns the prefix for the raw results' do + expect(failed_test_log.raw_results_paths[:prefix]).to eq '2022-04-05_18:37:28.338803_oTen' + end + + it 'returns nil if not present' do + expect(empty_test_log.raw_results_paths).to be_nil + end + end +end diff --git a/spec/firebase_test_runner_spec.rb b/spec/firebase_test_runner_spec.rb new file mode 100644 index 000000000..49a2b873c --- /dev/null +++ b/spec/firebase_test_runner_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe Fastlane::FirebaseTestRunner do + let(:default_file) { Tempfile.new('file').path } + let(:runner_temp_file) { Tempfile.new(%w[output log]).path } + + describe '#verify_has_gcloud_binary!' do + it 'runs the correct command' do + expect(Fastlane::Action).to receive('sh').with('command', '-v', 'gcloud', { print_command: false, print_command_output: false }) + described_class.verify_has_gcloud_binary! + end + + it 'raises for missing binary' do + allow(Fastlane::Action).to receive('sh').with('command', '-v', 'gcloud', { print_command: false, print_command_output: false }).and_raise + expect(Fastlane::UI).to receive(:user_error!) + described_class.verify_has_gcloud_binary! + end + end + + describe '#verify_logged_in!' do + it 'raises if not logged in' do + allow(Fastlane::FirebaseAccount).to receive(:authenticated?).and_return(false) + expect(Fastlane::UI).to receive(:user_error!) + described_class.verify_logged_in! + end + end + + describe '#run_tests' do + it 'runs the correct command' do + allow(Fastlane::Action).to receive('sh').with("gcloud firebase test android run --project foo-bar-baz --type instrumentation --app #{default_file} --test #{default_file} --device device --verbosity info 2>&1 | tee #{runner_temp_file}") + run_tests + end + + it 'properly escapes the app path' do + temp_file_path = File.join(Dir.tmpdir(), 'path with spaces.txt') + expected_temp_file_path = File.join(Dir.tmpdir(), 'path\ with\ spaces.txt') + File.write(temp_file_path, '') + + allow(Fastlane::Action).to receive('sh').with("gcloud firebase test android run --project foo-bar-baz --type instrumentation --app #{expected_temp_file_path} --test #{default_file} --device device --verbosity info 2>&1 | tee #{runner_temp_file}") + run_tests(apk_path: temp_file_path) + end + + it 'properly escapes the test path' do + temp_file_path = File.join(Dir.tmpdir(), 'path with spaces.txt') + expected_temp_file_path = File.join(Dir.tmpdir(), 'path\ with\ spaces.txt') + File.write(temp_file_path, '') + + allow(Fastlane::Action).to receive('sh').with("gcloud firebase test android run --project foo-bar-baz --type instrumentation --app #{default_file} --test #{expected_temp_file_path} --device device --verbosity info 2>&1 | tee #{runner_temp_file}") + run_tests(test_apk_path: temp_file_path) + end + + it 'properly escapes the device name' do + allow(Fastlane::Action).to receive('sh').with("gcloud firebase test android run --project foo-bar-baz --type instrumentation --app #{default_file} --test #{default_file} --device Nexus\\ 5 --verbosity info 2>&1 | tee #{runner_temp_file}") + run_tests(device: 'Nexus 5') + end + + it 'raises for invalid app path' do + expect { run_tests(apk_path: 'foo') }.to raise_exception('Unable to find apk: foo') + end + + it 'raises for invalid test path' do + expect { run_tests(test_apk_path: 'bar') }.to raise_exception('Unable to find apk: bar') + end + + it 'raises for invalid type' do + expect { run_tests(type: 'foo') }.to raise_exception('Invalid Type: foo') + end + + def run_tests(project_id: 'foo-bar-baz', apk_path: default_file, test_apk_path: default_file, device: 'device', type: 'instrumentation') + Fastlane::Actions.lane_context[:FIREBASE_TEST_LOG_FILE_PATH] = runner_temp_file + described_class.run_tests( + project_id: project_id, + apk_path: apk_path, + test_apk_path: test_apk_path, + device: device, + type: type + ) + end + end + + describe '#download_result_files' do + let(:empty_test_log) { Fastlane::FirebaseTestLabResult.new(log_file_path: EMPTY_FIREBASE_TEST_LOG_PATH) } + let(:passed_test_log) { Fastlane::FirebaseTestLabResult.new(log_file_path: PASSED_FIREBASE_TEST_LOG_PATH) } + + it 'raises for invalid result' do + expect { run_download(result: 'foo') }.to raise_exception('You must pass a `FirebaseTestLabResult` to this method') + end + + it 'raises for invalid destination' do + expect { run_download(result: empty_test_log) }.to raise_exception('Log File doesn\'t contain a raw results URL') + end + + def run_download(result: passed_test_log, destination: '/tmp/test', project_id: 'foo-bar-baz', key_file_path: 'invalid') + described_class.download_result_files( + result: result, + destination: destination, + project_id: project_id, + key_file_path: key_file_path + ) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0e7ab3656..65f5bbdf6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -98,3 +98,8 @@ def with_tmp_file(named: nil, content: '') File.delete(file_path) end end + +# File Path Helpers +EMPTY_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'empty.json') +PASSED_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-passed.log') +FAILED_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-failure.log') diff --git a/spec/test-data/firebase/firebase-authenticated-user-list.json b/spec/test-data/firebase/firebase-authenticated-user-list.json new file mode 100644 index 000000000..2707e3f5d --- /dev/null +++ b/spec/test-data/firebase/firebase-authenticated-user-list.json @@ -0,0 +1,6 @@ +[ + { + "account": "firebase-adminsdk-00000@project-name.iam.gserviceaccount.com", + "status": "ACTIVE" + } +] diff --git a/spec/test-data/firebase/firebase-empty-user-list.json b/spec/test-data/firebase/firebase-empty-user-list.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/spec/test-data/firebase/firebase-empty-user-list.json @@ -0,0 +1 @@ +[] diff --git a/spec/test-data/firebase/firebase-locale-list.json b/spec/test-data/firebase/firebase-locale-list.json new file mode 100644 index 000000000..1037e3738 --- /dev/null +++ b/spec/test-data/firebase/firebase-locale-list.json @@ -0,0 +1,2873 @@ +[ + { + "id": "af", + "name": "Afrikaans" + }, + { + "id": "af_NA", + "name": "Afrikaans", + "region": "Namibia" + }, + { + "id": "af_ZA", + "name": "Afrikaans", + "region": "South Africa" + }, + { + "id": "agq", + "name": "Aghem" + }, + { + "id": "agq_CM", + "name": "Aghem", + "region": "Cameroon" + }, + { + "id": "ak", + "name": "Akan" + }, + { + "id": "ak_GH", + "name": "Akan", + "region": "Ghana" + }, + { + "id": "am", + "name": "Amharic" + }, + { + "id": "am_ET", + "name": "Amharic", + "region": "Ethiopia" + }, + { + "id": "ar", + "name": "Arabic" + }, + { + "id": "ar_001", + "name": "Arabic", + "region": "World" + }, + { + "id": "ar_AE", + "name": "Arabic", + "region": "United Arab Emirates" + }, + { + "id": "ar_BH", + "name": "Arabic", + "region": "Bahrain" + }, + { + "id": "ar_DJ", + "name": "Arabic", + "region": "Djibouti" + }, + { + "id": "ar_DZ", + "name": "Arabic", + "region": "Algeria" + }, + { + "id": "ar_EG", + "name": "Arabic", + "region": "Egypt" + }, + { + "id": "ar_EH", + "name": "Arabic", + "region": "Western Sahara" + }, + { + "id": "ar_ER", + "name": "Arabic", + "region": "Eritrea" + }, + { + "id": "ar_IL", + "name": "Arabic", + "region": "Israel" + }, + { + "id": "ar_IQ", + "name": "Arabic", + "region": "Iraq" + }, + { + "id": "ar_JO", + "name": "Arabic", + "region": "Jordan" + }, + { + "id": "ar_KM", + "name": "Arabic", + "region": "Comoros" + }, + { + "id": "ar_KW", + "name": "Arabic", + "region": "Kuwait" + }, + { + "id": "ar_LB", + "name": "Arabic", + "region": "Lebanon" + }, + { + "id": "ar_LY", + "name": "Arabic", + "region": "Libya" + }, + { + "id": "ar_MA", + "name": "Arabic", + "region": "Morocco" + }, + { + "id": "ar_MR", + "name": "Arabic", + "region": "Mauritania" + }, + { + "id": "ar_OM", + "name": "Arabic", + "region": "Oman" + }, + { + "id": "ar_PS", + "name": "Arabic", + "region": "Palestine" + }, + { + "id": "ar_QA", + "name": "Arabic", + "region": "Qatar" + }, + { + "id": "ar_SA", + "name": "Arabic", + "region": "Saudi Arabia" + }, + { + "id": "ar_SD", + "name": "Arabic", + "region": "Sudan" + }, + { + "id": "ar_SO", + "name": "Arabic", + "region": "Somalia" + }, + { + "id": "ar_SS", + "name": "Arabic", + "region": "South Sudan" + }, + { + "id": "ar_SY", + "name": "Arabic", + "region": "Syria" + }, + { + "id": "ar_TD", + "name": "Arabic", + "region": "Chad" + }, + { + "id": "ar_TN", + "name": "Arabic", + "region": "Tunisia" + }, + { + "id": "ar_YE", + "name": "Arabic", + "region": "Yemen" + }, + { + "id": "as", + "name": "Assamese" + }, + { + "id": "as_IN", + "name": "Assamese", + "region": "India" + }, + { + "id": "asa", + "name": "Asu" + }, + { + "id": "asa_TZ", + "name": "Asu", + "region": "Tanzania" + }, + { + "id": "az", + "name": "Azerbaijani" + }, + { + "id": "az_AZ", + "name": "Azerbaijani", + "region": "Azerbaijan" + }, + { + "id": "bas", + "name": "Basaa" + }, + { + "id": "bas_CM", + "name": "Basaa", + "region": "Cameroon" + }, + { + "id": "be", + "name": "Belarusian" + }, + { + "id": "be_BY", + "name": "Belarusian", + "region": "Belarus" + }, + { + "id": "bem", + "name": "Bemba" + }, + { + "id": "bem_ZM", + "name": "Bemba", + "region": "Zambia" + }, + { + "id": "bez", + "name": "Bena" + }, + { + "id": "bez_TZ", + "name": "Bena", + "region": "Tanzania" + }, + { + "id": "bg", + "name": "Bulgarian" + }, + { + "id": "bg_BG", + "name": "Bulgarian", + "region": "Bulgaria" + }, + { + "id": "bm", + "name": "Bambara" + }, + { + "id": "bm_ML", + "name": "Bambara", + "region": "Mali" + }, + { + "id": "bn", + "name": "Bengali" + }, + { + "id": "bn_BD", + "name": "Bengali", + "region": "Bangladesh" + }, + { + "id": "bn_IN", + "name": "Bengali", + "region": "India" + }, + { + "id": "bo", + "name": "Tibetan" + }, + { + "id": "bo_CN", + "name": "Tibetan", + "region": "China" + }, + { + "id": "bo_IN", + "name": "Tibetan", + "region": "India" + }, + { + "id": "br", + "name": "Breton" + }, + { + "id": "br_FR", + "name": "Breton", + "region": "France" + }, + { + "id": "brx", + "name": "Bodo" + }, + { + "id": "brx_IN", + "name": "Bodo", + "region": "India" + }, + { + "id": "bs", + "name": "Bosnian" + }, + { + "id": "bs_BA", + "name": "Bosnian", + "region": "Bosnia and Herzegovina" + }, + { + "id": "ca", + "name": "Catalan" + }, + { + "id": "ca_AD", + "name": "Catalan", + "region": "Andorra" + }, + { + "id": "ca_ES", + "name": "Catalan", + "region": "Spain" + }, + { + "id": "ca_FR", + "name": "Catalan", + "region": "France" + }, + { + "id": "ca_IT", + "name": "Catalan", + "region": "Italy" + }, + { + "id": "cgg", + "name": "Chiga" + }, + { + "id": "cgg_UG", + "name": "Chiga", + "region": "Uganda" + }, + { + "id": "chr", + "name": "Cherokee" + }, + { + "id": "chr_US", + "name": "Cherokee", + "region": "United States" + }, + { + "id": "cs", + "name": "Czech" + }, + { + "id": "cs_CZ", + "name": "Czech", + "region": "Czech Republic" + }, + { + "id": "cy", + "name": "Welsh" + }, + { + "id": "cy_GB", + "name": "Welsh", + "region": "United Kingdom" + }, + { + "id": "da", + "name": "Danish" + }, + { + "id": "da_DK", + "name": "Danish", + "region": "Denmark" + }, + { + "id": "da_GL", + "name": "Danish", + "region": "Greenland" + }, + { + "id": "dav", + "name": "Taita" + }, + { + "id": "dav_KE", + "name": "Taita", + "region": "Kenya" + }, + { + "id": "de", + "name": "German" + }, + { + "id": "de_AT", + "name": "German", + "region": "Austria" + }, + { + "id": "de_BE", + "name": "German", + "region": "Belgium" + }, + { + "id": "de_CH", + "name": "German", + "region": "Switzerland" + }, + { + "id": "de_DE", + "name": "German", + "region": "Germany" + }, + { + "id": "de_LI", + "name": "German", + "region": "Liechtenstein" + }, + { + "id": "de_LU", + "name": "German", + "region": "Luxembourg" + }, + { + "id": "dje", + "name": "Zarma" + }, + { + "id": "dje_NE", + "name": "Zarma", + "region": "Niger" + }, + { + "id": "dua", + "name": "Duala" + }, + { + "id": "dua_CM", + "name": "Duala", + "region": "Cameroon" + }, + { + "id": "dyo", + "name": "Jola-Fonyi" + }, + { + "id": "dyo_SN", + "name": "Jola-Fonyi", + "region": "Senegal" + }, + { + "id": "dz", + "name": "Dzongkha" + }, + { + "id": "dz_BT", + "name": "Dzongkha", + "region": "Bhutan" + }, + { + "id": "ebu", + "name": "Embu" + }, + { + "id": "ebu_KE", + "name": "Embu", + "region": "Kenya" + }, + { + "id": "ee", + "name": "Ewe" + }, + { + "id": "ee_GH", + "name": "Ewe", + "region": "Ghana" + }, + { + "id": "ee_TG", + "name": "Ewe", + "region": "Togo" + }, + { + "id": "el", + "name": "Greek" + }, + { + "id": "el_CY", + "name": "Greek", + "region": "Cyprus" + }, + { + "id": "el_GR", + "name": "Greek", + "region": "Greece" + }, + { + "id": "en", + "name": "English", + "tags": [ + "default" + ] + }, + { + "id": "en_001", + "name": "English", + "region": "World" + }, + { + "id": "en_150", + "name": "English", + "region": "Europe" + }, + { + "id": "en_AG", + "name": "English", + "region": "Antigua and Barbuda" + }, + { + "id": "en_AI", + "name": "English", + "region": "Anguilla" + }, + { + "id": "en_AS", + "name": "English", + "region": "American Samoa" + }, + { + "id": "en_AU", + "name": "English", + "region": "Australia" + }, + { + "id": "en_BB", + "name": "English", + "region": "Barbados" + }, + { + "id": "en_BE", + "name": "English", + "region": "Belgium" + }, + { + "id": "en_BM", + "name": "English", + "region": "Bermuda" + }, + { + "id": "en_BS", + "name": "English", + "region": "Bahamas" + }, + { + "id": "en_BW", + "name": "English", + "region": "Botswana" + }, + { + "id": "en_BZ", + "name": "English", + "region": "Belize" + }, + { + "id": "en_CA", + "name": "English", + "region": "Canada" + }, + { + "id": "en_CC", + "name": "English", + "region": "Cocos (Keeling) Islands" + }, + { + "id": "en_CK", + "name": "English", + "region": "Cook Islands" + }, + { + "id": "en_CM", + "name": "English", + "region": "Cameroon" + }, + { + "id": "en_CX", + "name": "English", + "region": "Christmas Island" + }, + { + "id": "en_DG", + "name": "English", + "region": "Diego Garcia" + }, + { + "id": "en_DM", + "name": "English", + "region": "Dominica" + }, + { + "id": "en_ER", + "name": "English", + "region": "Eritrea" + }, + { + "id": "en_FJ", + "name": "English", + "region": "Fiji" + }, + { + "id": "en_FK", + "name": "English", + "region": "Falkland Islands (Islas Malvinas)" + }, + { + "id": "en_FM", + "name": "English", + "region": "Micronesia" + }, + { + "id": "en_GB", + "name": "English", + "region": "United Kingdom" + }, + { + "id": "en_GD", + "name": "English", + "region": "Grenada" + }, + { + "id": "en_GG", + "name": "English", + "region": "Guernsey" + }, + { + "id": "en_GH", + "name": "English", + "region": "Ghana" + }, + { + "id": "en_GI", + "name": "English", + "region": "Gibraltar" + }, + { + "id": "en_GM", + "name": "English", + "region": "Gambia" + }, + { + "id": "en_GU", + "name": "English", + "region": "Guam" + }, + { + "id": "en_GY", + "name": "English", + "region": "Guyana" + }, + { + "id": "en_HK", + "name": "English", + "region": "Hong Kong" + }, + { + "id": "en_IE", + "name": "English", + "region": "Ireland" + }, + { + "id": "en_IM", + "name": "English", + "region": "Isle of Man" + }, + { + "id": "en_IN", + "name": "English", + "region": "India" + }, + { + "id": "en_IO", + "name": "English", + "region": "British Indian Ocean Territory" + }, + { + "id": "en_JE", + "name": "English", + "region": "Jersey" + }, + { + "id": "en_JM", + "name": "English", + "region": "Jamaica" + }, + { + "id": "en_KE", + "name": "English", + "region": "Kenya" + }, + { + "id": "en_KI", + "name": "English", + "region": "Kiribati" + }, + { + "id": "en_KN", + "name": "English", + "region": "Saint Kitts and Nevis" + }, + { + "id": "en_KY", + "name": "English", + "region": "Cayman Islands" + }, + { + "id": "en_LC", + "name": "English", + "region": "Saint Lucia" + }, + { + "id": "en_LR", + "name": "English", + "region": "Liberia" + }, + { + "id": "en_LS", + "name": "English", + "region": "Lesotho" + }, + { + "id": "en_MG", + "name": "English", + "region": "Madagascar" + }, + { + "id": "en_MH", + "name": "English", + "region": "Marshall Islands" + }, + { + "id": "en_MO", + "name": "English", + "region": "Macau" + }, + { + "id": "en_MP", + "name": "English", + "region": "Northern Mariana Islands" + }, + { + "id": "en_MS", + "name": "English", + "region": "Montserrat" + }, + { + "id": "en_MT", + "name": "English", + "region": "Malta" + }, + { + "id": "en_MU", + "name": "English", + "region": "Mauritius" + }, + { + "id": "en_MW", + "name": "English", + "region": "Malawi" + }, + { + "id": "en_NA", + "name": "English", + "region": "Namibia" + }, + { + "id": "en_NF", + "name": "English", + "region": "Norfolk Island" + }, + { + "id": "en_NG", + "name": "English", + "region": "Nigeria" + }, + { + "id": "en_NR", + "name": "English", + "region": "Nauru" + }, + { + "id": "en_NU", + "name": "English", + "region": "Niue" + }, + { + "id": "en_NZ", + "name": "English", + "region": "New Zealand" + }, + { + "id": "en_PG", + "name": "English", + "region": "Papua New Guinea" + }, + { + "id": "en_PH", + "name": "English", + "region": "Philippines" + }, + { + "id": "en_PK", + "name": "English", + "region": "Pakistan" + }, + { + "id": "en_PN", + "name": "English", + "region": "Pitcairn Islands" + }, + { + "id": "en_PR", + "name": "English", + "region": "Puerto Rico" + }, + { + "id": "en_PW", + "name": "English", + "region": "Palau" + }, + { + "id": "en_RW", + "name": "English", + "region": "Rwanda" + }, + { + "id": "en_SB", + "name": "English", + "region": "Solomon Islands" + }, + { + "id": "en_SC", + "name": "English", + "region": "Seychelles" + }, + { + "id": "en_SD", + "name": "English", + "region": "Sudan" + }, + { + "id": "en_SG", + "name": "English", + "region": "Singapore" + }, + { + "id": "en_SH", + "name": "English", + "region": "Saint Helena" + }, + { + "id": "en_SL", + "name": "English", + "region": "Sierra Leone" + }, + { + "id": "en_SS", + "name": "English", + "region": "South Sudan" + }, + { + "id": "en_SX", + "name": "English", + "region": "Sint Maarten" + }, + { + "id": "en_SZ", + "name": "English", + "region": "Swaziland" + }, + { + "id": "en_TC", + "name": "English", + "region": "Turks and Caicos Islands" + }, + { + "id": "en_TK", + "name": "English", + "region": "Tokelau" + }, + { + "id": "en_TO", + "name": "English", + "region": "Tonga" + }, + { + "id": "en_TT", + "name": "English", + "region": "Trinidad and Tobago" + }, + { + "id": "en_TV", + "name": "English", + "region": "Tuvalu" + }, + { + "id": "en_TZ", + "name": "English", + "region": "Tanzania" + }, + { + "id": "en_UG", + "name": "English", + "region": "Uganda" + }, + { + "id": "en_UM", + "name": "English", + "region": "U.S. Outlying Islands" + }, + { + "id": "en_US", + "name": "English", + "region": "United States" + }, + { + "id": "en_VC", + "name": "English", + "region": "St. Vincent & Grenadines" + }, + { + "id": "en_VG", + "name": "English", + "region": "British Virgin Islands" + }, + { + "id": "en_VI", + "name": "English", + "region": "U.S. Virgin Islands" + }, + { + "id": "en_VU", + "name": "English", + "region": "Vanuatu" + }, + { + "id": "en_WS", + "name": "English", + "region": "Samoa" + }, + { + "id": "en_ZA", + "name": "English", + "region": "South Africa" + }, + { + "id": "en_ZM", + "name": "English", + "region": "Zambia" + }, + { + "id": "en_ZW", + "name": "English", + "region": "Zimbabwe" + }, + { + "id": "eo", + "name": "Esperanto" + }, + { + "id": "es", + "name": "Spanish" + }, + { + "id": "es_419", + "name": "Spanish", + "region": "Latin America" + }, + { + "id": "es_AR", + "name": "Spanish", + "region": "Argentina" + }, + { + "id": "es_BO", + "name": "Spanish", + "region": "Bolivia" + }, + { + "id": "es_CL", + "name": "Spanish", + "region": "Chile" + }, + { + "id": "es_CO", + "name": "Spanish", + "region": "Colombia" + }, + { + "id": "es_CR", + "name": "Spanish", + "region": "Costa Rica" + }, + { + "id": "es_CU", + "name": "Spanish", + "region": "Cuba" + }, + { + "id": "es_DO", + "name": "Spanish", + "region": "Dominican Republic" + }, + { + "id": "es_EA", + "name": "Spanish", + "region": "Ceuta and Melilla" + }, + { + "id": "es_EC", + "name": "Spanish", + "region": "Ecuador" + }, + { + "id": "es_ES", + "name": "Spanish", + "region": "Spain" + }, + { + "id": "es_GQ", + "name": "Spanish", + "region": "Equatorial Guinea" + }, + { + "id": "es_GT", + "name": "Spanish", + "region": "Guatemala" + }, + { + "id": "es_HN", + "name": "Spanish", + "region": "Honduras" + }, + { + "id": "es_IC", + "name": "Spanish", + "region": "Canary Islands" + }, + { + "id": "es_MX", + "name": "Spanish", + "region": "Mexico" + }, + { + "id": "es_NI", + "name": "Spanish", + "region": "Nicaragua" + }, + { + "id": "es_PA", + "name": "Spanish", + "region": "Panama" + }, + { + "id": "es_PE", + "name": "Spanish", + "region": "Peru" + }, + { + "id": "es_PH", + "name": "Spanish", + "region": "Philippines" + }, + { + "id": "es_PR", + "name": "Spanish", + "region": "Puerto Rico" + }, + { + "id": "es_PY", + "name": "Spanish", + "region": "Paraguay" + }, + { + "id": "es_SV", + "name": "Spanish", + "region": "El Salvador" + }, + { + "id": "es_US", + "name": "Spanish", + "region": "United States" + }, + { + "id": "es_UY", + "name": "Spanish", + "region": "Uruguay" + }, + { + "id": "es_VE", + "name": "Spanish", + "region": "Venezuela" + }, + { + "id": "et", + "name": "Estonian" + }, + { + "id": "et_EE", + "name": "Estonian", + "region": "Estonia" + }, + { + "id": "eu", + "name": "Basque" + }, + { + "id": "eu_ES", + "name": "Basque", + "region": "Spain" + }, + { + "id": "ewo", + "name": "Ewondo" + }, + { + "id": "ewo_CM", + "name": "Ewondo", + "region": "Cameroon" + }, + { + "id": "fa", + "name": "Persian" + }, + { + "id": "fa_AF", + "name": "Persian", + "region": "Afghanistan" + }, + { + "id": "fa_IR", + "name": "Persian", + "region": "Iran" + }, + { + "id": "ff", + "name": "Fulah" + }, + { + "id": "ff_SN", + "name": "Fulah", + "region": "Senegal" + }, + { + "id": "fi", + "name": "Finnish" + }, + { + "id": "fi_FI", + "name": "Finnish", + "region": "Finland" + }, + { + "id": "fil", + "name": "Filipino" + }, + { + "id": "fil_PH", + "name": "Filipino", + "region": "Philippines" + }, + { + "id": "fo", + "name": "Faroese" + }, + { + "id": "fo_FO", + "name": "Faroese", + "region": "Faroe Islands" + }, + { + "id": "fr", + "name": "French" + }, + { + "id": "fr_BE", + "name": "French", + "region": "Belgium" + }, + { + "id": "fr_BF", + "name": "French", + "region": "Burkina Faso" + }, + { + "id": "fr_BI", + "name": "French", + "region": "Burundi" + }, + { + "id": "fr_BJ", + "name": "French", + "region": "Benin" + }, + { + "id": "fr_BL", + "name": "French", + "region": "Saint Barthélemy" + }, + { + "id": "fr_CA", + "name": "French", + "region": "Canada" + }, + { + "id": "fr_CD", + "name": "French", + "region": "Congo (DRC)" + }, + { + "id": "fr_CF", + "name": "French", + "region": "Central African Republic" + }, + { + "id": "fr_CG", + "name": "French", + "region": "Congo (Republic)" + }, + { + "id": "fr_CH", + "name": "French", + "region": "Switzerland" + }, + { + "id": "fr_CI", + "name": "French", + "region": "Côte d’Ivoire" + }, + { + "id": "fr_CM", + "name": "French", + "region": "Cameroon" + }, + { + "id": "fr_DJ", + "name": "French", + "region": "Djibouti" + }, + { + "id": "fr_DZ", + "name": "French", + "region": "Algeria" + }, + { + "id": "fr_FR", + "name": "French", + "region": "France" + }, + { + "id": "fr_GA", + "name": "French", + "region": "Gabon" + }, + { + "id": "fr_GF", + "name": "French", + "region": "French Guiana" + }, + { + "id": "fr_GN", + "name": "French", + "region": "Guinea" + }, + { + "id": "fr_GP", + "name": "French", + "region": "Guadeloupe" + }, + { + "id": "fr_GQ", + "name": "French", + "region": "Equatorial Guinea" + }, + { + "id": "fr_HT", + "name": "French", + "region": "Haiti" + }, + { + "id": "fr_KM", + "name": "French", + "region": "Comoros" + }, + { + "id": "fr_LU", + "name": "French", + "region": "Luxembourg" + }, + { + "id": "fr_MA", + "name": "French", + "region": "Morocco" + }, + { + "id": "fr_MC", + "name": "French", + "region": "Monaco" + }, + { + "id": "fr_MF", + "name": "French", + "region": "Saint Martin" + }, + { + "id": "fr_MG", + "name": "French", + "region": "Madagascar" + }, + { + "id": "fr_ML", + "name": "French", + "region": "Mali" + }, + { + "id": "fr_MQ", + "name": "French", + "region": "Martinique" + }, + { + "id": "fr_MR", + "name": "French", + "region": "Mauritania" + }, + { + "id": "fr_MU", + "name": "French", + "region": "Mauritius" + }, + { + "id": "fr_NC", + "name": "French", + "region": "New Caledonia" + }, + { + "id": "fr_NE", + "name": "French", + "region": "Niger" + }, + { + "id": "fr_PF", + "name": "French", + "region": "French Polynesia" + }, + { + "id": "fr_PM", + "name": "French", + "region": "Saint Pierre and Miquelon" + }, + { + "id": "fr_RE", + "name": "French", + "region": "Réunion" + }, + { + "id": "fr_RW", + "name": "French", + "region": "Rwanda" + }, + { + "id": "fr_SC", + "name": "French", + "region": "Seychelles" + }, + { + "id": "fr_SN", + "name": "French", + "region": "Senegal" + }, + { + "id": "fr_SY", + "name": "French", + "region": "Syria" + }, + { + "id": "fr_TD", + "name": "French", + "region": "Chad" + }, + { + "id": "fr_TG", + "name": "French", + "region": "Togo" + }, + { + "id": "fr_TN", + "name": "French", + "region": "Tunisia" + }, + { + "id": "fr_VU", + "name": "French", + "region": "Vanuatu" + }, + { + "id": "fr_WF", + "name": "French", + "region": "Wallis and Futuna" + }, + { + "id": "fr_YT", + "name": "French", + "region": "Mayotte" + }, + { + "id": "ga", + "name": "Irish" + }, + { + "id": "ga_IE", + "name": "Irish", + "region": "Ireland" + }, + { + "id": "gl", + "name": "Galician" + }, + { + "id": "gl_ES", + "name": "Galician", + "region": "Spain" + }, + { + "id": "gsw", + "name": "Swiss German" + }, + { + "id": "gsw_CH", + "name": "Swiss German", + "region": "Switzerland" + }, + { + "id": "gsw_LI", + "name": "Swiss German", + "region": "Liechtenstein" + }, + { + "id": "gu", + "name": "Gujarati" + }, + { + "id": "gu_IN", + "name": "Gujarati", + "region": "India" + }, + { + "id": "guz", + "name": "Gusii" + }, + { + "id": "guz_KE", + "name": "Gusii", + "region": "Kenya" + }, + { + "id": "gv", + "name": "Manx" + }, + { + "id": "gv_IM", + "name": "Manx", + "region": "Isle of Man" + }, + { + "id": "ha", + "name": "Hausa" + }, + { + "id": "ha_GH", + "name": "Hausa", + "region": "Ghana" + }, + { + "id": "ha_NE", + "name": "Hausa", + "region": "Niger" + }, + { + "id": "ha_NG", + "name": "Hausa", + "region": "Nigeria" + }, + { + "id": "haw", + "name": "Hawaiian" + }, + { + "id": "haw_US", + "name": "Hawaiian", + "region": "United States" + }, + { + "id": "iw", + "name": "Hebrew" + }, + { + "id": "iw_IL", + "name": "Hebrew", + "region": "Israel" + }, + { + "id": "hi", + "name": "Hindi" + }, + { + "id": "hi_IN", + "name": "Hindi", + "region": "India" + }, + { + "id": "hr", + "name": "Croatian" + }, + { + "id": "hr_BA", + "name": "Croatian", + "region": "Bosnia and Herzegovina" + }, + { + "id": "hr_HR", + "name": "Croatian", + "region": "Croatia" + }, + { + "id": "hu", + "name": "Hungarian" + }, + { + "id": "hu_HU", + "name": "Hungarian", + "region": "Hungary" + }, + { + "id": "hy", + "name": "Armenian" + }, + { + "id": "hy_AM", + "name": "Armenian", + "region": "Armenia" + }, + { + "id": "in", + "name": "Indonesian" + }, + { + "id": "in_ID", + "name": "Indonesian", + "region": "Indonesia" + }, + { + "id": "ig", + "name": "Igbo" + }, + { + "id": "ig_NG", + "name": "Igbo", + "region": "Nigeria" + }, + { + "id": "ii", + "name": "Sichuan Yi" + }, + { + "id": "ii_CN", + "name": "Sichuan Yi", + "region": "China" + }, + { + "id": "is", + "name": "Icelandic" + }, + { + "id": "is_IS", + "name": "Icelandic", + "region": "Iceland" + }, + { + "id": "it", + "name": "Italian" + }, + { + "id": "it_CH", + "name": "Italian", + "region": "Switzerland" + }, + { + "id": "it_IT", + "name": "Italian", + "region": "Italy" + }, + { + "id": "it_SM", + "name": "Italian", + "region": "San Marino" + }, + { + "id": "ja", + "name": "Japanese" + }, + { + "id": "ja_JP", + "name": "Japanese", + "region": "Japan" + }, + { + "id": "jgo", + "name": "Ngomba" + }, + { + "id": "jgo_CM", + "name": "Ngomba", + "region": "Cameroon" + }, + { + "id": "jmc", + "name": "Machame" + }, + { + "id": "jmc_TZ", + "name": "Machame", + "region": "Tanzania" + }, + { + "id": "ka", + "name": "Georgian" + }, + { + "id": "ka_GE", + "name": "Georgian", + "region": "Georgia" + }, + { + "id": "kab", + "name": "Kabyle" + }, + { + "id": "kab_DZ", + "name": "Kabyle", + "region": "Algeria" + }, + { + "id": "kam", + "name": "Kamba" + }, + { + "id": "kam_KE", + "name": "Kamba", + "region": "Kenya" + }, + { + "id": "kde", + "name": "Makonde" + }, + { + "id": "kde_TZ", + "name": "Makonde", + "region": "Tanzania" + }, + { + "id": "kea", + "name": "Kabuverdianu" + }, + { + "id": "kea_CV", + "name": "Kabuverdianu", + "region": "Cape Verde" + }, + { + "id": "khq", + "name": "Koyra Chiini" + }, + { + "id": "khq_ML", + "name": "Koyra Chiini", + "region": "Mali" + }, + { + "id": "ki", + "name": "Kikuyu" + }, + { + "id": "ki_KE", + "name": "Kikuyu", + "region": "Kenya" + }, + { + "id": "kk", + "name": "Kazakh" + }, + { + "id": "kk_KZ", + "name": "Kazakh", + "region": "Kazakhstan" + }, + { + "id": "kkj", + "name": "Kako" + }, + { + "id": "kkj_CM", + "name": "Kako", + "region": "Cameroon" + }, + { + "id": "kl", + "name": "Kalaallisut" + }, + { + "id": "kl_GL", + "name": "Kalaallisut", + "region": "Greenland" + }, + { + "id": "kln", + "name": "Kalenjin" + }, + { + "id": "kln_KE", + "name": "Kalenjin", + "region": "Kenya" + }, + { + "id": "km", + "name": "Khmer" + }, + { + "id": "km_KH", + "name": "Khmer", + "region": "Cambodia" + }, + { + "id": "kn", + "name": "Kannada" + }, + { + "id": "kn_IN", + "name": "Kannada", + "region": "India" + }, + { + "id": "ko", + "name": "Korean" + }, + { + "id": "ko_KP", + "name": "Korean", + "region": "North Korea" + }, + { + "id": "ko_KR", + "name": "Korean", + "region": "South Korea" + }, + { + "id": "kok", + "name": "Konkani" + }, + { + "id": "kok_IN", + "name": "Konkani", + "region": "India" + }, + { + "id": "ks", + "name": "Kashmiri" + }, + { + "id": "ks_IN", + "name": "Kashmiri", + "region": "India" + }, + { + "id": "ksb", + "name": "Shambala" + }, + { + "id": "ksb_TZ", + "name": "Shambala", + "region": "Tanzania" + }, + { + "id": "ksf", + "name": "Bafia" + }, + { + "id": "ksf_CM", + "name": "Bafia", + "region": "Cameroon" + }, + { + "id": "kw", + "name": "Cornish" + }, + { + "id": "kw_GB", + "name": "Cornish", + "region": "United Kingdom" + }, + { + "id": "ky", + "name": "Kyrgyz" + }, + { + "id": "ky_KG", + "name": "Kyrgyz", + "region": "Kyrgyzstan" + }, + { + "id": "lag", + "name": "Langi" + }, + { + "id": "lag_TZ", + "name": "Langi", + "region": "Tanzania" + }, + { + "id": "lg", + "name": "Ganda" + }, + { + "id": "lg_UG", + "name": "Ganda", + "region": "Uganda" + }, + { + "id": "lkt", + "name": "Lakota" + }, + { + "id": "lkt_US", + "name": "Lakota", + "region": "United States" + }, + { + "id": "ln", + "name": "Lingala" + }, + { + "id": "ln_AO", + "name": "Lingala", + "region": "Angola" + }, + { + "id": "ln_CD", + "name": "Lingala", + "region": "Congo (DRC)" + }, + { + "id": "ln_CF", + "name": "Lingala", + "region": "Central African Republic" + }, + { + "id": "ln_CG", + "name": "Lingala", + "region": "Congo (Republic)" + }, + { + "id": "lo", + "name": "Lao" + }, + { + "id": "lo_LA", + "name": "Lao", + "region": "Laos" + }, + { + "id": "lt", + "name": "Lithuanian" + }, + { + "id": "lt_LT", + "name": "Lithuanian", + "region": "Lithuania" + }, + { + "id": "lu", + "name": "Luba-Katanga" + }, + { + "id": "lu_CD", + "name": "Luba-Katanga", + "region": "Congo (DRC)" + }, + { + "id": "luo", + "name": "Luo" + }, + { + "id": "luo_KE", + "name": "Luo", + "region": "Kenya" + }, + { + "id": "luy", + "name": "Luyia" + }, + { + "id": "luy_KE", + "name": "Luyia", + "region": "Kenya" + }, + { + "id": "lv", + "name": "Latvian" + }, + { + "id": "lv_LV", + "name": "Latvian", + "region": "Latvia" + }, + { + "id": "mas", + "name": "Masai" + }, + { + "id": "mas_KE", + "name": "Masai", + "region": "Kenya" + }, + { + "id": "mas_TZ", + "name": "Masai", + "region": "Tanzania" + }, + { + "id": "mer", + "name": "Meru" + }, + { + "id": "mer_KE", + "name": "Meru", + "region": "Kenya" + }, + { + "id": "mfe", + "name": "Morisyen" + }, + { + "id": "mfe_MU", + "name": "Morisyen", + "region": "Mauritius" + }, + { + "id": "mg", + "name": "Malagasy" + }, + { + "id": "mg_MG", + "name": "Malagasy", + "region": "Madagascar" + }, + { + "id": "mgh", + "name": "Makhuwa-Meetto" + }, + { + "id": "mgh_MZ", + "name": "Makhuwa-Meetto", + "region": "Mozambique" + }, + { + "id": "mgo", + "name": "Meta'" + }, + { + "id": "mgo_CM", + "name": "Meta'", + "region": "Cameroon" + }, + { + "id": "mk", + "name": "Macedonian" + }, + { + "id": "mk_MK", + "name": "Macedonian", + "region": "Macedonia (FYROM)" + }, + { + "id": "ml", + "name": "Malayalam" + }, + { + "id": "ml_IN", + "name": "Malayalam", + "region": "India" + }, + { + "id": "mn", + "name": "Mongolian" + }, + { + "id": "mn_MN", + "name": "Mongolian", + "region": "Mongolia" + }, + { + "id": "mr", + "name": "Marathi" + }, + { + "id": "mr_IN", + "name": "Marathi", + "region": "India" + }, + { + "id": "ms", + "name": "Malay" + }, + { + "id": "ms_BN", + "name": "Malay", + "region": "Brunei" + }, + { + "id": "ms_MY", + "name": "Malay", + "region": "Malaysia" + }, + { + "id": "ms_SG", + "name": "Malay", + "region": "Singapore" + }, + { + "id": "mt", + "name": "Maltese" + }, + { + "id": "mt_MT", + "name": "Maltese", + "region": "Malta" + }, + { + "id": "mua", + "name": "Mundang" + }, + { + "id": "mua_CM", + "name": "Mundang", + "region": "Cameroon" + }, + { + "id": "my", + "name": "Burmese" + }, + { + "id": "my_MM", + "name": "Burmese", + "region": "Myanmar (Burma)" + }, + { + "id": "naq", + "name": "Nama" + }, + { + "id": "naq_NA", + "name": "Nama", + "region": "Namibia" + }, + { + "id": "nb", + "name": "Norwegian Bokmål" + }, + { + "id": "nb_NO", + "name": "Norwegian Bokmål", + "region": "Norway" + }, + { + "id": "nb_SJ", + "name": "Norwegian Bokmål", + "region": "Svalbard and Jan Mayen" + }, + { + "id": "nd", + "name": "North Ndebele" + }, + { + "id": "nd_ZW", + "name": "North Ndebele", + "region": "Zimbabwe" + }, + { + "id": "ne", + "name": "Nepali" + }, + { + "id": "ne_IN", + "name": "Nepali", + "region": "India" + }, + { + "id": "ne_NP", + "name": "Nepali", + "region": "Nepal" + }, + { + "id": "nl", + "name": "Dutch" + }, + { + "id": "nl_AW", + "name": "Dutch", + "region": "Aruba" + }, + { + "id": "nl_BE", + "name": "Dutch", + "region": "Belgium" + }, + { + "id": "nl_BQ", + "name": "Dutch", + "region": "Caribbean Netherlands" + }, + { + "id": "nl_CW", + "name": "Dutch", + "region": "Curaçao" + }, + { + "id": "nl_NL", + "name": "Dutch", + "region": "Netherlands" + }, + { + "id": "nl_SR", + "name": "Dutch", + "region": "Suriname" + }, + { + "id": "nl_SX", + "name": "Dutch", + "region": "Sint Maarten" + }, + { + "id": "nmg", + "name": "Kwasio" + }, + { + "id": "nmg_CM", + "name": "Kwasio", + "region": "Cameroon" + }, + { + "id": "nn", + "name": "Norwegian Nynorsk" + }, + { + "id": "nn_NO", + "name": "Norwegian Nynorsk", + "region": "Norway" + }, + { + "id": "nnh", + "name": "Ngiemboon" + }, + { + "id": "nnh_CM", + "name": "Ngiemboon", + "region": "Cameroon" + }, + { + "id": "nus", + "name": "Nuer" + }, + { + "id": "nus_SD", + "name": "Nuer", + "region": "Sudan" + }, + { + "id": "nyn", + "name": "Nyankole" + }, + { + "id": "nyn_UG", + "name": "Nyankole", + "region": "Uganda" + }, + { + "id": "om", + "name": "Oromo" + }, + { + "id": "om_ET", + "name": "Oromo", + "region": "Ethiopia" + }, + { + "id": "om_KE", + "name": "Oromo", + "region": "Kenya" + }, + { + "id": "or", + "name": "Oriya" + }, + { + "id": "or_IN", + "name": "Oriya", + "region": "India" + }, + { + "id": "pa", + "name": "Punjabi" + }, + { + "id": "pa_PK", + "name": "Punjabi", + "region": "Pakistan" + }, + { + "id": "pa_IN", + "name": "Punjabi", + "region": "India" + }, + { + "id": "pl", + "name": "Polish" + }, + { + "id": "pl_PL", + "name": "Polish", + "region": "Poland" + }, + { + "id": "ps", + "name": "Pashto" + }, + { + "id": "ps_AF", + "name": "Pashto", + "region": "Afghanistan" + }, + { + "id": "pt", + "name": "Portuguese" + }, + { + "id": "pt_AO", + "name": "Portuguese", + "region": "Angola" + }, + { + "id": "pt_BR", + "name": "Portuguese", + "region": "Brazil" + }, + { + "id": "pt_CV", + "name": "Portuguese", + "region": "Cape Verde" + }, + { + "id": "pt_GW", + "name": "Portuguese", + "region": "Guinea-Bissau" + }, + { + "id": "pt_MO", + "name": "Portuguese", + "region": "Macau" + }, + { + "id": "pt_MZ", + "name": "Portuguese", + "region": "Mozambique" + }, + { + "id": "pt_PT", + "name": "Portuguese", + "region": "Portugal" + }, + { + "id": "pt_ST", + "name": "Portuguese", + "region": "São Tomé and Príncipe" + }, + { + "id": "pt_TL", + "name": "Portuguese", + "region": "Timor-Leste" + }, + { + "id": "rm", + "name": "Romansh" + }, + { + "id": "rm_CH", + "name": "Romansh", + "region": "Switzerland" + }, + { + "id": "rn", + "name": "Rundi" + }, + { + "id": "rn_BI", + "name": "Rundi", + "region": "Burundi" + }, + { + "id": "ro", + "name": "Romanian" + }, + { + "id": "ro_MD", + "name": "Romanian", + "region": "Moldova" + }, + { + "id": "ro_RO", + "name": "Romanian", + "region": "Romania" + }, + { + "id": "rof", + "name": "Rombo" + }, + { + "id": "rof_TZ", + "name": "Rombo", + "region": "Tanzania" + }, + { + "id": "ru", + "name": "Russian" + }, + { + "id": "ru_BY", + "name": "Russian", + "region": "Belarus" + }, + { + "id": "ru_KG", + "name": "Russian", + "region": "Kyrgyzstan" + }, + { + "id": "ru_KZ", + "name": "Russian", + "region": "Kazakhstan" + }, + { + "id": "ru_MD", + "name": "Russian", + "region": "Moldova" + }, + { + "id": "ru_RU", + "name": "Russian", + "region": "Russia" + }, + { + "id": "ru_UA", + "name": "Russian", + "region": "Ukraine" + }, + { + "id": "rw", + "name": "Kinyarwanda" + }, + { + "id": "rw_RW", + "name": "Kinyarwanda", + "region": "Rwanda" + }, + { + "id": "rwk", + "name": "Rwa" + }, + { + "id": "rwk_TZ", + "name": "Rwa", + "region": "Tanzania" + }, + { + "id": "saq", + "name": "Samburu" + }, + { + "id": "saq_KE", + "name": "Samburu", + "region": "Kenya" + }, + { + "id": "sbp", + "name": "Sangu" + }, + { + "id": "sbp_TZ", + "name": "Sangu", + "region": "Tanzania" + }, + { + "id": "seh", + "name": "Sena" + }, + { + "id": "seh_MZ", + "name": "Sena", + "region": "Mozambique" + }, + { + "id": "ses", + "name": "Koyraboro Senni" + }, + { + "id": "ses_ML", + "name": "Koyraboro Senni", + "region": "Mali" + }, + { + "id": "sg", + "name": "Sango" + }, + { + "id": "sg_CF", + "name": "Sango", + "region": "Central African Republic" + }, + { + "id": "shi", + "name": "Tachelhit" + }, + { + "id": "shi_MA", + "name": "Tachelhit", + "region": "Morocco" + }, + { + "id": "si", + "name": "Sinhala" + }, + { + "id": "si_LK", + "name": "Sinhala", + "region": "Sri Lanka" + }, + { + "id": "sk", + "name": "Slovak" + }, + { + "id": "sk_SK", + "name": "Slovak", + "region": "Slovakia" + }, + { + "id": "sl", + "name": "Slovenian" + }, + { + "id": "sl_SI", + "name": "Slovenian", + "region": "Slovenia" + }, + { + "id": "sn", + "name": "Shona" + }, + { + "id": "sn_ZW", + "name": "Shona", + "region": "Zimbabwe" + }, + { + "id": "so", + "name": "Somali" + }, + { + "id": "so_DJ", + "name": "Somali", + "region": "Djibouti" + }, + { + "id": "so_ET", + "name": "Somali", + "region": "Ethiopia" + }, + { + "id": "so_KE", + "name": "Somali", + "region": "Kenya" + }, + { + "id": "so_SO", + "name": "Somali", + "region": "Somalia" + }, + { + "id": "sq", + "name": "Albanian" + }, + { + "id": "sq_AL", + "name": "Albanian", + "region": "Albania" + }, + { + "id": "sq_MK", + "name": "Albanian", + "region": "Macedonia (FYROM)" + }, + { + "id": "sq_XK", + "name": "Albanian", + "region": "Kosovo" + }, + { + "id": "sr", + "name": "Serbian" + }, + { + "id": "sr_BA", + "name": "Serbian", + "region": "Bosnia and Herzegovina" + }, + { + "id": "sr_ME", + "name": "Serbian", + "region": "Montenegro" + }, + { + "id": "sr_RS", + "name": "Serbian", + "region": "Serbia" + }, + { + "id": "sr_XK", + "name": "Serbian", + "region": "Kosovo" + }, + { + "id": "sv", + "name": "Swedish" + }, + { + "id": "sv_AX", + "name": "Swedish", + "region": "Åland Islands" + }, + { + "id": "sv_FI", + "name": "Swedish", + "region": "Finland" + }, + { + "id": "sv_SE", + "name": "Swedish", + "region": "Sweden" + }, + { + "id": "sw", + "name": "Swahili" + }, + { + "id": "sw_KE", + "name": "Swahili", + "region": "Kenya" + }, + { + "id": "sw_TZ", + "name": "Swahili", + "region": "Tanzania" + }, + { + "id": "sw_UG", + "name": "Swahili", + "region": "Uganda" + }, + { + "id": "swc", + "name": "Congo Swahili" + }, + { + "id": "swc_CD", + "name": "Congo Swahili", + "region": "Congo (DRC)" + }, + { + "id": "ta", + "name": "Tamil" + }, + { + "id": "ta_IN", + "name": "Tamil", + "region": "India" + }, + { + "id": "ta_LK", + "name": "Tamil", + "region": "Sri Lanka" + }, + { + "id": "ta_MY", + "name": "Tamil", + "region": "Malaysia" + }, + { + "id": "ta_SG", + "name": "Tamil", + "region": "Singapore" + }, + { + "id": "te", + "name": "Telugu" + }, + { + "id": "te_IN", + "name": "Telugu", + "region": "India" + }, + { + "id": "teo", + "name": "Teso" + }, + { + "id": "teo_KE", + "name": "Teso", + "region": "Kenya" + }, + { + "id": "teo_UG", + "name": "Teso", + "region": "Uganda" + }, + { + "id": "th", + "name": "Thai" + }, + { + "id": "th_TH", + "name": "Thai", + "region": "Thailand" + }, + { + "id": "ti", + "name": "Tigrinya" + }, + { + "id": "ti_ER", + "name": "Tigrinya", + "region": "Eritrea" + }, + { + "id": "ti_ET", + "name": "Tigrinya", + "region": "Ethiopia" + }, + { + "id": "to", + "name": "Tongan" + }, + { + "id": "to_TO", + "name": "Tongan", + "region": "Tonga" + }, + { + "id": "tr", + "name": "Turkish" + }, + { + "id": "tr_CY", + "name": "Turkish", + "region": "Cyprus" + }, + { + "id": "tr_TR", + "name": "Turkish", + "region": "Turkey" + }, + { + "id": "twq", + "name": "Tasawaq" + }, + { + "id": "twq_NE", + "name": "Tasawaq", + "region": "Niger" + }, + { + "id": "tzm", + "name": "Central Atlas Tamazight" + }, + { + "id": "tzm_MA", + "name": "Central Atlas Tamazight", + "region": "Morocco" + }, + { + "id": "ug", + "name": "Uyghur" + }, + { + "id": "ug_CN", + "name": "Uyghur", + "region": "China" + }, + { + "id": "uk", + "name": "Ukrainian" + }, + { + "id": "uk_UA", + "name": "Ukrainian", + "region": "Ukraine" + }, + { + "id": "ur", + "name": "Urdu" + }, + { + "id": "ur_IN", + "name": "Urdu", + "region": "India" + }, + { + "id": "ur_PK", + "name": "Urdu", + "region": "Pakistan" + }, + { + "id": "uz", + "name": "Uzbek" + }, + { + "id": "uz_AF", + "name": "Uzbek", + "region": "Afghanistan" + }, + { + "id": "uz_UZ", + "name": "Uzbek", + "region": "Uzbekistan" + }, + { + "id": "vai", + "name": "Vai" + }, + { + "id": "vai_LR", + "name": "Vai", + "region": "Liberia" + }, + { + "id": "vi", + "name": "Vietnamese" + }, + { + "id": "vi_VN", + "name": "Vietnamese", + "region": "Vietnam" + }, + { + "id": "vun", + "name": "Vunjo" + }, + { + "id": "vun_TZ", + "name": "Vunjo", + "region": "Tanzania" + }, + { + "id": "xog", + "name": "Soga" + }, + { + "id": "xog_UG", + "name": "Soga", + "region": "Uganda" + }, + { + "id": "yav", + "name": "Yangben" + }, + { + "id": "yav_CM", + "name": "Yangben", + "region": "Cameroon" + }, + { + "id": "yo", + "name": "Yoruba" + }, + { + "id": "yo_BJ", + "name": "Yoruba", + "region": "Benin" + }, + { + "id": "yo_NG", + "name": "Yoruba", + "region": "Nigeria" + }, + { + "id": "zgh", + "name": "Standard Moroccan Tamazight" + }, + { + "id": "zgh_MA", + "name": "Standard Moroccan Tamazight", + "region": "Morocco" + }, + { + "id": "zh", + "name": "Chinese" + }, + { + "id": "zh_CN", + "name": "Chinese", + "region": "China" + }, + { + "id": "zh_HK", + "name": "Chinese", + "region": "Hong Kong" + }, + { + "id": "zh_MO", + "name": "Chinese", + "region": "Macau" + }, + { + "id": "zh_SG", + "name": "Chinese", + "region": "Singapore" + }, + { + "id": "zh_TW", + "name": "Chinese", + "region": "Taiwan" + }, + { + "id": "zu", + "name": "Zulu" + }, + { + "id": "zu_ZA", + "name": "Zulu", + "region": "South Africa" + } +] diff --git a/spec/test-data/firebase/firebase-model-list.json b/spec/test-data/firebase/firebase-model-list.json new file mode 100644 index 000000000..2580071f0 --- /dev/null +++ b/spec/test-data/firebase/firebase-model-list.json @@ -0,0 +1,1732 @@ +[ + { + "brand": "vivo", + "codename": "1610", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "1610", + "manufacturer": "Vivo", + "name": "vivo 1610", + "screenDensity": 320, + "screenX": 720, + "screenY": 1280, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "23" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/SqwncaPUUGjIC0sosZBMkbYsu1p8WEj2uYz4fjgBcxRoe1u9Ti4JBJKXya5i8sbf_UQifSxzSlmb" + }, + { + "brand": "vivo", + "codename": "1725", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "1725", + "manufacturer": "Vivo", + "name": "vivo 1725", + "screenDensity": 480, + "screenX": 1080, + "screenY": 2280, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "tags": [ + "deprecated=27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/ExXbgtjpgeKdLG52-0gafTkfIFBdqr_kN_5npDPffZOA8RtB4uPnkoV_GO74kR2LWUZvJm4vYpTsxw" + }, + { + "brand": "Nokia", + "codename": "AOP_sprout", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "AOP_sprout", + "manufacturer": "HMD Global", + "name": "Nokia 9", + "screenDensity": 560, + "screenX": 1440, + "screenY": 2880, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/-NAm8zoviK2NaO03XDSRxnMBrABfD3bguKdD0OlQzB2g41n__Tfa_Ail5SXgDiR2QYXPzkua2kgZDA" + }, + { + "brand": "asus", + "codename": "ASUS_X00T_3", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "ASUS_X00T_3", + "manufacturer": "Asus", + "name": "ASUS_X00TD", + "screenDensity": 480, + "screenX": 2160, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27", + "28" + ] + }, + { + "brand": "Google", + "codename": "AmatiTvEmulator", + "form": "EMULATOR", + "id": "AmatiTvEmulator", + "manufacturer": "Google", + "name": "Google TV Amati", + "screenDensity": 320, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "x86", + "29:armeabi", + "29:armeabi-v7a" + ], + "supportedVersionIds": [ + "29" + ], + "tags": [ + "beta=29", + "alpha" + ] + }, + { + "brand": "Nokia", + "codename": "FRT", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "FRT", + "manufacturer": "HMD Global", + "name": "Nokia 1", + "screenDensity": 240, + "screenX": 480, + "screenY": 854, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/S1o2WB_jvLqf6frd_CSBPe1-aCOrykz3cbjCbfJGViwc9m_-Nxf0aYII63qnb7OpEZ19dMoAEUqw" + }, + { + "brand": "Sony", + "codename": "G8142", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "G8142", + "manufacturer": "Sony", + "name": "G8142", + "screenDensity": 480, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "25", + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/-qrrnvxN1PFjHVaBSuVmDexxMXCHm6G1oquO-HX-gbb6MITH2HIC7CiJy5Nhq_EDUQE7-e3Y2qhJ" + }, + { + "brand": "Sony", + "codename": "G8342", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "G8342", + "manufacturer": "Sony", + "name": "G8342", + "screenDensity": 480, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/6kl2pZEURkS3hLzDbyBIJ4zochFRDM6RHn7dRPmsXEG1Nfu2EAGK9WfSBf5V8Lk1EBKpnEGkU-o9" + }, + { + "brand": "Sony", + "codename": "G8441", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "G8441", + "manufacturer": "Sony", + "name": "G8441", + "screenDensity": 320, + "screenX": 1280, + "screenY": 720, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/GKkzomYlZGEMWh5ks3ggxuFGtLLt2WQL8ECoGODek7Z7WrS_ZrusNegAaXxGidDe7NhNIRJHNwLh" + }, + { + "brand": "Google", + "codename": "GoogleTvEmulator", + "form": "EMULATOR", + "id": "GoogleTvEmulator", + "manufacturer": "Google", + "name": "Google TV", + "screenDensity": 213, + "screenX": 1280, + "screenY": 720, + "supportedAbis": [ + "x86" + ], + "supportedVersionIds": [ + "30" + ], + "tags": [ + "beta=30", + "alpha" + ] + }, + { + "brand": "Sony", + "codename": "H8216", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "H8216", + "manufacturer": "Sony", + "name": "H8216", + "screenDensity": 480, + "screenX": 1080, + "screenY": 2160, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/NGTtvMwJdYHOGkfyAXz0NWg66qX1aNpAaL68erhY8CdHHOuyO10x9kwkzsre7bef27he64hDsaRL" + }, + { + "brand": "Sony", + "codename": "H8296", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "H8296", + "manufacturer": "Sony", + "name": "H8296", + "screenDensity": 480, + "screenX": 1080, + "screenY": 2160, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "tags": [ + "deprecated=28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/NGTtvMwJdYHOGkfyAXz0NWg66qX1aNpAaL68erhY8CdHHOuyO10x9kwkzsre7bef27he64hDsaRL" + }, + { + "brand": "Sony", + "codename": "H8314", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "H8314", + "manufacturer": "Sony", + "name": "H8314", + "screenDensity": 480, + "screenX": 2160, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/Jn6t1R7TpcUSG3Rjt1Zc3Hbpq3c2U_COfy3p-VBItWaJ5XPXXAmIzYrO85pWpJ2iaZbLptDGFj2Y" + }, + { + "brand": "Sony", + "codename": "H8324", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "H8324", + "manufacturer": "Sony", + "name": "H8324", + "screenDensity": 480, + "screenX": 1080, + "screenY": 2160, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/Jn6t1R7TpcUSG3Rjt1Zc3Hbpq3c2U_COfy3p-VBItWaJ5XPXXAmIzYrO85pWpJ2iaZbLptDGFj2Y" + }, + { + "brand": "Sony", + "codename": "H9493", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "H9493", + "manufacturer": "Sony", + "name": "H9493", + "screenDensity": 560, + "screenX": 1440, + "screenY": 2880, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/6WhvS2NcOJPVSBfHexaXg22G8SbOkLDyi8nv3D6u4--hZKJK8wMFEFMo-oxB4Pyg5_ciPepSmDMvkQ" + }, + { + "brand": "HUAWEI", + "codename": "HWANE-LX1", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "HWANE-LX1", + "manufacturer": "Huawei", + "name": "ANE-LX1", + "screenDensity": 480, + "screenX": 2280, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ] + }, + { + "brand": "HUAWEI", + "codename": "HWANE-LX2", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "HWANE-LX2", + "manufacturer": "Huawei", + "name": "ANE-LX2", + "screenDensity": 480, + "screenX": 2280, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ] + }, + { + "brand": "HONOR", + "codename": "HWCOR", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "HWCOR", + "manufacturer": "Huawei", + "name": "COR-L29", + "screenDensity": 480, + "screenX": 2340, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/U8VkhxAM2xAOXTTqi1Zt4v_r4_1FQmTKCcqKG-uwFvoJarYuFVj-pb37RKOKgtKvlhC_n2ATgHKQ" + }, + { + "brand": "HUAWEI", + "codename": "HWMHA", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "HWMHA", + "manufacturer": "Huawei", + "name": "MHA-L29", + "screenDensity": 480, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "24" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/kWhwyvrchgAaa_hbhAH2uq1Wj_sPy6Jc98_Cs6Ju9K29NPH6tPnUk0SLfAbTmh1Zle-Vh4ibbUs" + }, + { + "brand": "Google", + "codename": "Nexus10", + "form": "VIRTUAL", + "formFactor": "TABLET", + "id": "Nexus10", + "manufacturer": "Samsung", + "name": "Nexus 10", + "screenDensity": 320, + "screenX": 1600, + "screenY": 2560, + "supportedAbis": [ + "x86" + ], + "supportedVersionIds": [ + "19", + "21", + "22" + ] + }, + { + "brand": "Google", + "codename": "Nexus4", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Nexus4", + "manufacturer": "LG", + "name": "Nexus 4", + "screenDensity": 320, + "screenX": 768, + "screenY": 1280, + "supportedAbis": [ + "x86" + ], + "supportedVersionIds": [ + "19", + "21", + "22" + ] + }, + { + "brand": "Google", + "codename": "Nexus5", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Nexus5", + "manufacturer": "LG", + "name": "Nexus 5", + "screenDensity": 480, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "x86", + "23:armeabi", + "23:armeabi-v7a" + ], + "supportedVersionIds": [ + "19", + "21", + "22", + "23" + ] + }, + { + "brand": "Google", + "codename": "Nexus5X", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Nexus5X", + "manufacturer": "LG", + "name": "Nexus 5X", + "screenDensity": 420, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "x86", + "23:armeabi", + "23:armeabi-v7a", + "24:armeabi", + "24:armeabi-v7a", + "25:armeabi", + "25:armeabi-v7a", + "26:armeabi", + "26:armeabi-v7a" + ], + "supportedVersionIds": [ + "23", + "24", + "25", + "26" + ] + }, + { + "brand": "Google", + "codename": "Nexus6", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Nexus6", + "manufacturer": "Motorola", + "name": "Nexus 6", + "screenDensity": 560, + "screenX": 1440, + "screenY": 2560, + "supportedAbis": [ + "x86", + "23:armeabi", + "23:armeabi-v7a", + "24:armeabi", + "24:armeabi-v7a", + "25:armeabi", + "25:armeabi-v7a" + ], + "supportedVersionIds": [ + "21", + "22", + "23", + "24", + "25" + ] + }, + { + "brand": "Google", + "codename": "Nexus6P", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Nexus6P", + "manufacturer": "Google", + "name": "Nexus 6P", + "screenDensity": 560, + "screenX": 1440, + "screenY": 2560, + "supportedAbis": [ + "x86", + "23:armeabi", + "23:armeabi-v7a", + "24:armeabi", + "24:armeabi-v7a", + "25:armeabi", + "25:armeabi-v7a", + "26:armeabi", + "26:armeabi-v7a", + "27:armeabi", + "27:armeabi-v7a" + ], + "supportedVersionIds": [ + "23", + "24", + "25", + "26", + "27" + ] + }, + { + "brand": "Google", + "codename": "Nexus7", + "form": "VIRTUAL", + "formFactor": "TABLET", + "id": "Nexus7", + "manufacturer": "Asus", + "name": "Nexus 7 (2012)", + "screenDensity": 213, + "screenX": 800, + "screenY": 1280, + "supportedAbis": [ + "x86" + ], + "supportedVersionIds": [ + "19", + "21", + "22" + ] + }, + { + "brand": "Generic", + "codename": "Nexus7_clone_16_9", + "form": "VIRTUAL", + "formFactor": "TABLET", + "id": "Nexus7_clone_16_9", + "manufacturer": "Generic", + "name": "Nexus7 clone, DVD 16:9 aspect ratio", + "screenDensity": 160, + "screenX": 720, + "screenY": 1280, + "supportedAbis": [ + "x86", + "23:armeabi", + "23:armeabi-v7a", + "24:armeabi", + "24:armeabi-v7a", + "25:armeabi", + "25:armeabi-v7a", + "26:armeabi", + "26:armeabi-v7a" + ], + "supportedVersionIds": [ + "23", + "24", + "25", + "26" + ], + "tags": [ + "beta" + ] + }, + { + "brand": "Google", + "codename": "Nexus9", + "form": "VIRTUAL", + "formFactor": "TABLET", + "id": "Nexus9", + "manufacturer": "HTC", + "name": "Nexus 9", + "screenDensity": 320, + "screenX": 1536, + "screenY": 2048, + "supportedAbis": [ + "x86", + "23:armeabi", + "23:armeabi-v7a", + "24:armeabi", + "24:armeabi-v7a", + "25:armeabi", + "25:armeabi-v7a" + ], + "supportedVersionIds": [ + "21", + "22", + "23", + "24", + "25" + ] + }, + { + "brand": "Generic", + "codename": "NexusLowRes", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "NexusLowRes", + "manufacturer": "Generic", + "name": "Low-resolution MDPI phone", + "screenDensity": 160, + "screenX": 360, + "screenY": 640, + "supportedAbis": [ + "23:armeabi", + "23:armeabi-v7a", + "23:x86", + "24:armeabi", + "24:armeabi-v7a", + "24:x86", + "25:armeabi", + "25:armeabi-v7a", + "25:x86", + "26:armeabi", + "26:armeabi-v7a", + "26:x86", + "27:armeabi", + "27:armeabi-v7a", + "27:x86", + "28:armeabi", + "28:armeabi-v7a", + "28:x86", + "29:armeabi", + "29:armeabi-v7a", + "29:x86", + "30:x86", + "31:x86_64" + ], + "supportedVersionIds": [ + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30" + ] + }, + { + "brand": "OnePlus", + "codename": "OnePlus3T", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "OnePlus3T", + "manufacturer": "OnePlus", + "name": "ONEPLUS A3003", + "screenDensity": 420, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ] + }, + { + "brand": "OnePlus", + "codename": "OnePlus5T", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "OnePlus5T", + "manufacturer": "OnePlus", + "name": "ONEPLUS A5010", + "screenDensity": 420, + "screenX": 2160, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/76XeI65AZsVnDMFpOTPl-J5OoferZTj5gqXAnUO6zokvmkJyZa12HdVcLiPC4CLWCi__7_hZ77lw" + }, + { + "brand": "Google", + "codename": "Pixel2", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Pixel2", + "manufacturer": "Google", + "name": "Pixel 2", + "screenDensity": 441, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "26:armeabi", + "26:armeabi-v7a", + "26:x86", + "27:armeabi", + "27:armeabi-v7a", + "27:x86", + "28:armeabi", + "28:armeabi-v7a", + "28:x86", + "29:armeabi", + "29:armeabi-v7a", + "29:x86", + "30:x86", + "31:x86_64" + ], + "supportedVersionIds": [ + "26", + "27", + "28", + "29", + "30" + ] + }, + { + "brand": "google", + "codename": "Pixel3", + "form": "VIRTUAL", + "formFactor": "PHONE", + "id": "Pixel3", + "manufacturer": "Google", + "name": "Pixel 3", + "screenDensity": 440, + "screenX": 1080, + "screenY": 2160, + "supportedAbis": [ + "30:x86" + ], + "supportedVersionIds": [ + "30" + ] + }, + { + "brand": "samsung", + "codename": "SC-02J", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "SC-02J", + "manufacturer": "Samsung", + "name": "SC-02J", + "screenDensity": 640, + "screenX": 1440, + "screenY": 2960, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "tags": [ + "deprecated=28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/a4IoNjNfOAOHaBRnlxBYZcuAiTh5z4MeD44Fb_xQl07Yjnr2oqEpb7hu6ESCB7wVNpPKzYKg3ZyZ" + }, + { + "brand": "samsung", + "codename": "SC-02K", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "SC-02K", + "manufacturer": "Samsung", + "name": "SC-02K", + "screenDensity": 480, + "screenX": 1080, + "screenY": 2220, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/v2jQ18NEhtqVlsv8iPtZ1rAmN1Cg50knOA7BrBfL1PCthU6hGI8T_r2XP_dfpcPxQxtixmJfNZs" + }, + { + "brand": "DOCOMO", + "codename": "SH-01L", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "SH-01L", + "manufacturer": "SHARP", + "name": "SH-01L", + "screenDensity": 480, + "screenX": 1080, + "screenY": 2160, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/IzqBfPK4K340vsj6RNw_FArIfGujmOO0ZuDDEH7Ex6bY-CjcM62abnNYzjsPOyLnnC5TC8bOS3c" + }, + { + "brand": "DOCOMO", + "codename": "SH-03K", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "SH-03K", + "manufacturer": "SHARP", + "name": "SH-03K", + "screenDensity": 640, + "screenX": 1440, + "screenY": 3040, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/a-UkZJXIaIJdfT30CkVB1GF4dWCPWC6jzXCr8d3Wu3-aFMRjYpVpcV0eVMwFeI7p0GWXRvQVmRs" + }, + { + "brand": "Lenovo", + "codename": "TB-8504F", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "TB-8504F", + "manufacturer": "Lenovo", + "name": "Lenovo TB-8504F", + "screenDensity": 213, + "screenX": 1280, + "screenY": 800, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "tags": [ + "deprecated=27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/ZR4nKTSdXC-r5E4suo22fHgnNkikuN8RzQ7_VWnIYyQXVgcsl5dLOnQw564reJrzpnWJTpmkmZU" + }, + { + "brand": "Zebra", + "codename": "TC77", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "TC77", + "manufacturer": "Zebra Technologies", + "name": "TC77", + "screenDensity": 320, + "screenX": 720, + "screenY": 1280, + "supportedAbis": [ + "arm64-v8a", + "armeabi-v7a", + "armeabi" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/OKgEGt-Q8n6K1u938350qha7iwmacDY0dlDKDwE9iQVUEMkPEoOrqfbV99BYXO1Ekbb4YvlagsOE" + }, + { + "brand": "samsung", + "codename": "a10", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "a10", + "manufacturer": "Samsung", + "name": "SM-A105FN", + "screenDensity": 280, + "screenX": 1520, + "screenY": 720, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "29" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/mZ0aEweFVm2lfRMMHC_jzQGufpnv8A74dQhijFe76GyVqWZT8Fz1BwjLLBAxxay93Gak6TVlQJw" + }, + { + "brand": "google", + "codename": "blueline", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "blueline", + "manufacturer": "Google", + "name": "Pixel 3", + "screenDensity": 440, + "screenX": 1080, + "screenY": 2160, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "tags": [ + "default" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/A-RPvqzMpVIUpyVmgwDawhYjSsYIGRquDl1cCKqvO-QAx9UnMR4IFfaY0ge5IQZxwzSguthlzkmgFw" + }, + { + "brand": "xiaomi", + "codename": "cactus", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "cactus", + "manufacturer": "Xiaomi", + "name": "Redmi 6A", + "screenDensity": 320, + "screenX": 720, + "screenY": 1440, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/c07v2Tu4nxWOaaLJKr266xcvZ1Tc7Yacjhv5gcc9weh2lWekmmdm6lviTScreGiJEO1uGMiVais" + }, + { + "brand": "samsung", + "codename": "cruiserlteatt", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "cruiserlteatt", + "manufacturer": "Samsung", + "name": "SM-G892A", + "screenDensity": 480, + "screenX": 2220, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/73bZIZkHnD9kfKfv_DOnIUr5k8m7__enM67o9e8cxa-IQjzq0CwQ0jMxK7XW8lxLcpzZD4I1hDCEhA" + }, + { + "brand": "samsung", + "codename": "dreamlte", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "dreamlte", + "manufacturer": "Samsung", + "name": "SM-G950F", + "screenDensity": 480, + "screenX": 2220, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/05r0yPMGNnmgxgOop_j5jn85arZO-Xgr0a7OrKLzv5XD7jarBHDAHmq_nEubDs0PEJH6beX4bBbl" + }, + { + "brand": "samsung", + "codename": "f2q", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "f2q", + "manufacturer": "Samsung", + "name": "SM-F916U1", + "screenDensity": 480, + "screenX": 1768, + "screenY": 2208, + "supportedAbis": [ + "arm64-v8a", + "armeabi-v7a", + "armeabi" + ], + "supportedVersionIds": [ + "30" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/T_MXb1ci-Q4Xl_g_XV19Yqa0LEbZQfEf8vZD4lKLPuFNe5t5gnCtsPbIB9Dvhz0D9xu8wEzOgNO6" + }, + { + "brand": "google", + "codename": "flo", + "form": "PHYSICAL", + "formFactor": "TABLET", + "id": "flo", + "manufacturer": "Asus", + "name": "Nexus 7", + "screenDensity": 320, + "screenX": 1920, + "screenY": 1200, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "19" + ], + "thumbnailUrl": "https://lh3.ggpht.com/DYFkgrJuwYBWu1_ib6GUhKqszsUx__EyGXf2y_5112_GAiwKm8lj5Me0ySRIAhzvnHy5ayVEpHHpWg" + }, + { + "brand": "samsung", + "codename": "grandppltedx", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "grandppltedx", + "manufacturer": "Samsung", + "name": "SM-G532G", + "screenDensity": 240, + "screenX": 960, + "screenY": 540, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "23" + ] + }, + { + "brand": "samsung", + "codename": "greatlteks", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "greatlteks", + "manufacturer": "Samsung", + "name": "SM-N950N", + "screenDensity": 420, + "screenX": 2220, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "tags": [ + "deprecated=28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/nHJL8Nw9yUSxJj1q9WskGJZtTltl0XqOGhvw1HlA-wSITh6v7LimsPUBehXKUZYgRYgPYI3Fm1cQGQ" + }, + { + "brand": "motorola", + "codename": "griffin", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "griffin", + "manufacturer": "Motorola", + "name": "XT1650", + "screenDensity": 640, + "screenX": 2560, + "screenY": 1440, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "24" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/LiBYTSr3xQ7VHF62fDVPs3JgQ8d_F441YWuuWAbWYkiH7FRy_wyko-DyfsHV2kc8erE1uv4s9t9V" + }, + { + "brand": "Verizon", + "codename": "gts3lltevzw", + "form": "PHYSICAL", + "formFactor": "TABLET", + "id": "gts3lltevzw", + "manufacturer": "Samsung", + "name": "SM-T827V", + "screenDensity": 320, + "screenX": 2048, + "screenY": 1536, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/Ei1LFgQvf4Yj3wDCKdnge1mlusicePcwVnsSFqFcTtbJUecMZAEy_cjfLTKNwXDxs1lZJtgto-HVaw" + }, + { + "brand": "Verizon", + "codename": "gts4lltevzw", + "form": "PHYSICAL", + "formFactor": "TABLET", + "id": "gts4lltevzw", + "manufacturer": "Samsung", + "name": "SM-T837V", + "screenDensity": 360, + "screenX": 1600, + "screenY": 2560, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/2TsL3i7Gs_ILH8AJzGGDNQEoBWzog5WxbZmXLVHmS8PonYQ5rGqqmr_m0v79xHMCBQRdJQaCLPE" + }, + { + "brand": "samsung", + "codename": "gts4lvwifi", + "form": "PHYSICAL", + "formFactor": "TABLET", + "id": "gts4lvwifi", + "manufacturer": "Samsung", + "name": "SM-T720", + "screenDensity": 360, + "screenX": 2560, + "screenY": 1600, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/MAA0l3w2lD9yTdnaNqmjfQ5z98wh-sS_TPqV9OWPHuHPFuAHHa-Wvfaeyb9HAX8xHnxZRRZsCHk" + }, + { + "brand": "google", + "codename": "hammerhead", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "hammerhead", + "manufacturer": "LG", + "name": "Nexus 5", + "screenDensity": 480, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "23" + ], + "thumbnailUrl": "https://lh5.ggpht.com/qxU0oYP3cwwYZMG_xkfwOQE2yVzFIbKaE1xxkBtA9UBncP6XyItLc85-cTLtFA_lZNHmMH7Pxdo" + }, + { + "brand": "motorola", + "codename": "harpia", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "harpia", + "manufacturer": "Motorola", + "name": "Moto G Play", + "screenDensity": 320, + "screenX": 720, + "screenY": 1280, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "23" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/aKod2b8C3siC0gmFd476ckJ3akKySdQgpJgpUMfNcKJWHWhr9B_r8BuQezNk8cmIx_WGeGUC6xrHbg" + }, + { + "brand": "samsung", + "codename": "hero2qltespr", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "hero2qltespr", + "manufacturer": "Samsung", + "name": "SM-G935P", + "screenDensity": 480, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/u8CP15P3dZsy4qjDp66NMgZbA90g4IeBPmGoJ37EPMhhrgh5rUN8UN1vdaIWOM9vsQLDBhltcwe22A" + }, + { + "brand": "samsung", + "codename": "heroqlteaio", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "heroqlteaio", + "manufacturer": "Samsung", + "name": "SAMSUNG-SM-G930AZ", + "screenDensity": 480, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/1YV68rNeQzvTs5eFTgO8sQ2SHqslXStqHKTWjjsi8tetd6fZ3x-3MMYZe_ZG1boIDE6TI-gr-3Tlbw" + }, + { + "brand": "samsung", + "codename": "heroqltemtr", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "heroqltemtr", + "manufacturer": "Samsung", + "name": "SM-G930T1", + "screenDensity": 480, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/Dezj2uqK0M4PivAyh2MY2EY7obubJ4DDUmkERZdHUSy7B7ZEDoiBzZay-V9ipparMnN7C1BZmW4e" + }, + { + "brand": "samsung", + "codename": "heroqltetfnvzw", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "heroqltetfnvzw", + "manufacturer": "Samsung", + "name": "SM-G930VL", + "screenDensity": 640, + "screenX": 2560, + "screenY": 1440, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/TkihAZOyE2uz4EAPDi4sHpuNN6gsBike5dt7Vbwdgst0HeGG0nIUMGcM865ry2BcaNR_dq9pHcMrkA" + }, + { + "brand": "samsung", + "codename": "heroqlteue", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "heroqlteue", + "manufacturer": "Samsung", + "name": "SM-G930U", + "screenDensity": 480, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/-sCzrgvFmLYreO-nnkRK7xxXlR2p0PcqTbMedlqOw5JslVVmzNTsB8UuXqFtCnYIm6wCZ91CkEpccQ" + }, + { + "brand": "samsung", + "codename": "heroqlteusc", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "heroqlteusc", + "manufacturer": "Samsung", + "name": "SM-G930R4", + "screenDensity": 480, + "screenX": 1920, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/1-j9d2MNOc54DM06zlQBWnZNeM91a8YrjiQCxF7IZGnnrOlzAK9POZG6yqUnQTdWzW5M7R3KRGceww" + }, + { + "brand": "Verizon", + "codename": "heroqltevzw", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "heroqltevzw", + "manufacturer": "Samsung", + "name": "SM-G930V", + "screenDensity": 640, + "screenX": 2560, + "screenY": 1440, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "tags": [ + "deprecated=26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/0c7YTryaEE_nRoXg6DPWNakaMyyV42dbQCCApH2AW9WOvBQrmyrXpH8-oBibW_MhXN4zVzZyWLRo7g" + }, + { + "brand": "htc", + "codename": "htc_ocmdugl", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "htc_ocmdugl", + "manufacturer": "HTC", + "name": "HTC U11 plus", + "screenDensity": 640, + "screenX": 2880, + "screenY": 1440, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/ZiZPQzxOmdoquCCClxTQU_oSZmpN1TNE5LnVJrCB3uGECKvjIoD056U5J0gpbAMig9YqPNRs1kL5" + }, + { + "brand": "htc", + "codename": "htc_pmeuhl", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "htc_pmeuhl", + "manufacturer": "HTC", + "name": "HTC 10", + "screenDensity": 640, + "screenX": 2560, + "screenY": 1440, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/VtAbWkLGTD8e5hEE5CL4KV_Op2Cfdsi05pmfVHPh20IafBH2j4atshpsGhlKa7s5KpBRSa7t1w64" + }, + { + "brand": "Huawei", + "codename": "hwALE-H", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "hwALE-H", + "manufacturer": "Huawei", + "name": "ALE-L23", + "screenDensity": 320, + "screenX": 720, + "screenY": 1280, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "21" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/kHH92lgwYTlHSr2gx4jDsLGOqzUAPwPL-lEbopIkZwcpxPRX3V1zip9u6JQ8vo3ELo5bcfHXieJcHA" + }, + { + "brand": "Verizon", + "codename": "j7popltevzw", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "j7popltevzw", + "manufacturer": "Samsung", + "name": "SM-J727V", + "screenDensity": 320, + "screenX": 720, + "screenY": 1280, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/eORX0ROwItAAKaXHUIses4o2i1VlvTVEJ53guLrkpCRiGvBL7KGktYlEqrgkyl5CnqnYqUYKJQgL0A" + }, + { + "brand": "lge", + "codename": "joan", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "joan", + "manufacturer": "LG", + "name": "LG-H932", + "screenDensity": 640, + "screenX": 2880, + "screenY": 1440, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/_RGBhW19dHQww5npD_uCSgv48m_ziTOC1KAXSK0dDCAwkq4snw-rLUmmUcI7IryOEcleAaHj-fM" + }, + { + "brand": "lge", + "codename": "judypn", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "judypn", + "manufacturer": "LG", + "name": "LM-V405", + "screenDensity": 560, + "screenX": 1440, + "screenY": 3120, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/79-OKmsXWRQW-gO3SleYA7IR_BLTOizZJiZyq0mhJM2j9HIsfzFnPD5y1VVOEfEqGTuzTlosCes" + }, + { + "brand": "samsung", + "codename": "lt02wifi", + "form": "PHYSICAL", + "formFactor": "TABLET", + "id": "lt02wifi", + "manufacturer": "Samsung", + "name": "SM-T210", + "screenDensity": 160, + "screenX": 1024, + "screenY": 600, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "19" + ], + "thumbnailUrl": "https://lh5.ggpht.com/ZSda2eYJgosreODD1rvNNpbCzU74L5FJafqAYUX_K4FevNBoU7eLk9asLIjfdcW9lRq8TaaFATQ" + }, + { + "brand": "lge", + "codename": "lv0", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "lv0", + "manufacturer": "LG", + "name": "LG-AS110", + "screenDensity": 240, + "screenX": 480, + "screenY": 854, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "23" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/7g_xh8i3kF7oHMjStyLLfI3cyXScJUO47YUUZHS9nw6EPYjebUKHUtPoynSqk8KE-H4SzxgqZpN9" + }, + { + "brand": "google", + "codename": "oriole", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "oriole", + "manufacturer": "Google", + "name": "Oriole", + "screenDensity": 420, + "screenX": 1080, + "screenY": 2400, + "supportedAbis": [ + "arm64-v8a", + "armeabi-v7a", + "armeabi" + ], + "supportedVersionIds": [ + "31" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/NHjiDpnVPpfRgpXRroEWwojXOXrnu_V04k5Hpw0_1QmDvcjk5jtdEvJDC8zoL20dz0839Vhy9ow" + }, + { + "brand": "motorola", + "codename": "pettyl", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "pettyl", + "manufacturer": "Motorola", + "name": "moto e5 play", + "screenDensity": 240, + "screenX": 480, + "screenY": 960, + "supportedAbis": [ + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/TuUs3GBwEfKr1u6JNSyQ9yTXeLbJMOz249N7ASi-od_wBoXUmxQ93lHBZV6TsahQU0B8ai5ZPCHG0g" + }, + { + "brand": "lge", + "codename": "phoenix_sprout", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "phoenix_sprout", + "manufacturer": "LG", + "name": "LM-Q910", + "screenDensity": 560, + "screenX": 1440, + "screenY": 3120, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "28" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/8zZqpl8B71D9kRPoif7390Fz3pl3repFenyF0tpsDipppHB0LjAe5rcEF3BO88F_wUPJIgHoRMHagw" + }, + { + "brand": "google", + "codename": "redfin", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "redfin", + "manufacturer": "Google", + "name": "Pixel 5e", + "screenDensity": 440, + "screenX": 1080, + "screenY": 2340, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "30" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/yTrlQMFILGX8Y1yKlsZYMxsLeQjPzOiNMXH7b5do5k4L7gnYl0qYLEg_JxvG0TmRt9kzlJNu_-8" + }, + { + "brand": "google", + "codename": "sailfish", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "sailfish", + "manufacturer": "Google", + "name": "Pixel", + "screenDensity": 420, + "screenX": 480, + "screenY": 640, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "25" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/Y695akw6GQifgofN_GNrZQMTgTZgxnsMg6ZoQNX84xor7Zxmk7IU0N0GnE-YYha40lqFLH6Fa7qW" + }, + { + "brand": "samsung", + "codename": "starqlteue", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "starqlteue", + "manufacturer": "Samsung", + "name": "SM-G960U1", + "screenDensity": 480, + "screenX": 2220, + "screenY": 1080, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "26" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/xD06N1P46ZHuvYUnWkZBd5Alqr5pkkbreynLQ29yRO4-YFE3yDqix9MFUy-9rC0aLmSOb9UJOeI7" + }, + { + "brand": "google", + "codename": "walleye", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "walleye", + "manufacturer": "Google", + "name": "Pixel 2", + "screenDensity": 420, + "screenX": 1080, + "screenY": 1920, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "27" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/j4urvb3lXTaFGZI6IzHmAjum2HQVID1OHPhDB7dOzRvXb2WscSX2RFwEEFFSYhajqRO5Yu0e6FYQ" + }, + { + "brand": "samsung", + "codename": "x1q", + "form": "PHYSICAL", + "formFactor": "PHONE", + "id": "x1q", + "manufacturer": "Samsung", + "name": "SM-G981U1", + "screenDensity": 640, + "screenX": 1440, + "screenY": 3200, + "supportedAbis": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "supportedVersionIds": [ + "29" + ], + "thumbnailUrl": "https://lh3.googleusercontent.com/FLdP9p2yRcQ1aeg9fa1BWN4Q5EGDh6rT7XX2Qk_p4m3jjPwRwT5IoybWEq1j2yDKpXVKKw6SndY" + } +] diff --git a/spec/test-data/firebase/firebase-test-lab-run-failure.log b/spec/test-data/firebase/firebase-test-lab-run-failure.log new file mode 100644 index 000000000..da9f609ab --- /dev/null +++ b/spec/test-data/firebase/firebase-test-lab-run-failure.log @@ -0,0 +1,58 @@ +INFO: Test Service endpoint: [None] +INFO: Tool Results endpoint: [None] + +Have questions, feedback, or issues? Get support by visiting: + https://firebase.google.com/support/ + +INFO: Raw results root path is: [gs://test-lab-wjdmcn8vd90jx-wfb9uburfx80m/2022-04-05_18:37:28.338803_oTen/] +Uploading [/Users/jkmassel/Projects/woocommerce-android/WooCommerce/build/outputs/apk/vanilla/debug/WooCommerce-vanilla-debug.apk] to Firebase Test Lab... +Uploading [/Users/jkmassel/Projects/woocommerce-android/WooCommerce/build/outputs/apk/androidTest/vanilla/debug/WooCommerce-vanilla-debug-androidTest.apk] to Firebase Test Lab... +Raw results will be stored in your GCS bucket at [https://console.developers.google.com/storage/browser/test-lab-wjdmcn8vd90jx-wfb9uburfx80m/2022-04-05_18:37:28.338803_oTen/] + +Test [matrix-kxvyandoeuq4a] has been created in the Google Cloud. +Firebase Test Lab will execute your instrumentation test on 1 device(s). +Creating individual test executions... +..............................................................done. + +Test results will be streamed to [https://console.firebase.google.com/project/redacted/testlab/histories/bh.edfd947f2636efe3/matrices/4770383643393920434]. +18:37:57 Test is Pending +18:42:37 Starting attempt 1. +18:42:37 Test is Running +18:44:15 Started logcat recording. +18:44:15 Started crash monitoring. +18:44:15 Preparing device. +18:44:51 Logging in to Google account on device. +18:44:51 Installing apps. +18:45:52 Retrieving Performance Environment information from the device. +18:45:52 Setting up Android test. +18:45:52 Started crash detection. +18:45:52 Started Out of memory detection +18:45:52 Started performance monitoring. +18:45:52 Starting Android test. +18:46:05 Completed Android test. +18:46:17 Stopped performance monitoring. +18:46:17 Tearing down Android test. +18:46:17 Logging out of Google account on device. +18:46:29 Stopped crash monitoring. +18:46:29 Stopped logcat recording. +18:46:29 Done. Test time = 3 (secs) +18:46:29 Starting results processing. Attempt: 1 +18:46:41 Completed results processing. Time taken = 6 (secs) +18:46:41 Test is Finished +INFO: Test matrix completed in state: FINISHED + +Instrumentation testing complete. + +More details are available at [https://console.firebase.google.com/project/redacted/testlab/histories/bh.edfd947f2636efe3/matrices/4770383643393920434]. +INFO: Display format: " + table[box]( + outcome.color(red=Fail, green=Pass, blue=Flaky, yellow=Inconclusive), + axis_value:label=TEST_AXIS_VALUE, + test_details:label=TEST_DETAILS + ) +" +┌─────────┬───────────────────────┬────────────────────────────────┐ +│ OUTCOME │ TEST_AXIS_VALUE │ TEST_DETAILS │ +├─────────┼───────────────────────┼────────────────────────────────┤ +│ Failed  │ Nexus5-23-en-portrait │ 3 test cases failed, 20 passed │ +└─────────┴───────────────────────┴────────────────────────────────┘ diff --git a/spec/test-data/firebase/firebase-test-lab-run-passed.log b/spec/test-data/firebase/firebase-test-lab-run-passed.log new file mode 100644 index 000000000..b5eeeba74 --- /dev/null +++ b/spec/test-data/firebase/firebase-test-lab-run-passed.log @@ -0,0 +1,58 @@ +INFO: Test Service endpoint: [None] +INFO: Tool Results endpoint: [None] + +Have questions, feedback, or issues? Get support by visiting: + https://firebase.google.com/support/ + +INFO: Raw results root path is: [gs://test-lab-wjdmcn8vd90jx-wfb9uburfx80m/2022-04-05_18:37:28.338803_oTen/] +Uploading [/Users/jkmassel/Projects/woocommerce-android/WooCommerce/build/outputs/apk/vanilla/debug/WooCommerce-vanilla-debug.apk] to Firebase Test Lab... +Uploading [/Users/jkmassel/Projects/woocommerce-android/WooCommerce/build/outputs/apk/androidTest/vanilla/debug/WooCommerce-vanilla-debug-androidTest.apk] to Firebase Test Lab... +Raw results will be stored in your GCS bucket at [https://console.developers.google.com/storage/browser/test-lab-wjdmcn8vd90jx-wfb9uburfx80m/2022-04-05_18:37:28.338803_oTen/] + +Test [matrix-kxvyandoeuq4a] has been created in the Google Cloud. +Firebase Test Lab will execute your instrumentation test on 1 device(s). +Creating individual test executions... +..............................................................done. + +Test results will be streamed to [https://console.firebase.google.com/project/redacted/testlab/histories/bh.edfd947f2636efe3/matrices/4770383643393920434]. +18:37:57 Test is Pending +18:42:37 Starting attempt 1. +18:42:37 Test is Running +18:44:15 Started logcat recording. +18:44:15 Started crash monitoring. +18:44:15 Preparing device. +18:44:51 Logging in to Google account on device. +18:44:51 Installing apps. +18:45:52 Retrieving Performance Environment information from the device. +18:45:52 Setting up Android test. +18:45:52 Started crash detection. +18:45:52 Started Out of memory detection +18:45:52 Started performance monitoring. +18:45:52 Starting Android test. +18:46:05 Completed Android test. +18:46:17 Stopped performance monitoring. +18:46:17 Tearing down Android test. +18:46:17 Logging out of Google account on device. +18:46:29 Stopped crash monitoring. +18:46:29 Stopped logcat recording. +18:46:29 Done. Test time = 3 (secs) +18:46:29 Starting results processing. Attempt: 1 +18:46:41 Completed results processing. Time taken = 6 (secs) +18:46:41 Test is Finished +INFO: Test matrix completed in state: FINISHED + +Instrumentation testing complete. + +More details are available at [https://console.firebase.google.com/project/redacted/testlab/histories/bh.edfd947f2636efe3/matrices/4770383643393920434]. +INFO: Display format: " + table[box]( + outcome.color(red=Fail, green=Pass, blue=Flaky, yellow=Inconclusive), + axis_value:label=TEST_AXIS_VALUE, + test_details:label=TEST_DETAILS + ) +" +┌─────────┬───────────────────────┬──────────────────────────────────┐ +│ OUTCOME │ TEST_AXIS_VALUE │ TEST_DETAILS │ +├─────────┼───────────────────────┼──────────────────────────────────┤ +│ Passed │ Pixel2-28-en-portrait │ 118 test cases passed, 5 skipped │ +└─────────┴───────────────────────┴──────────────────────────────────┘ diff --git a/spec/test-data/firebase/firebase-version-list.json b/spec/test-data/firebase/firebase-version-list.json new file mode 100644 index 000000000..4f79e1b33 --- /dev/null +++ b/spec/test-data/firebase/firebase-version-list.json @@ -0,0 +1,148 @@ +[ + { + "apiLevel": 18, + "codeName": "Jelly Bean", + "id": "18", + "releaseDate": { + "day": 3, + "month": 10, + "year": 2013 + }, + "versionString": "4.3.x" + }, + { + "apiLevel": 19, + "codeName": "KitKat", + "id": "19", + "releaseDate": { + "day": 2, + "month": 6, + "year": 2014 + }, + "versionString": "4.4.x" + }, + { + "apiLevel": 21, + "codeName": "Lollipop", + "id": "21", + "releaseDate": { + "day": 19, + "month": 12, + "year": 2014 + }, + "versionString": "5.0.x" + }, + { + "apiLevel": 22, + "codeName": "Lollipop", + "id": "22", + "releaseDate": { + "day": 21, + "month": 4, + "year": 2015 + }, + "versionString": "5.1.x" + }, + { + "apiLevel": 23, + "codeName": "Marshmallow", + "id": "23", + "releaseDate": { + "day": 5, + "month": 10, + "year": 2015 + }, + "versionString": "6.0.x" + }, + { + "apiLevel": 24, + "codeName": "Nougat", + "id": "24", + "releaseDate": { + "day": 22, + "month": 8, + "year": 2016 + }, + "versionString": "7.0.x" + }, + { + "apiLevel": 25, + "codeName": "Nougat", + "id": "25", + "releaseDate": { + "day": 19, + "month": 10, + "year": 2016 + }, + "versionString": "7.1.x" + }, + { + "apiLevel": 26, + "codeName": "Oreo", + "id": "26", + "releaseDate": { + "day": 21, + "month": 8, + "year": 2017 + }, + "versionString": "8.0.x" + }, + { + "apiLevel": 27, + "codeName": "Oreo MR1", + "id": "27", + "releaseDate": { + "day": 4, + "month": 12, + "year": 2017 + }, + "versionString": "8.1.x" + }, + { + "apiLevel": 28, + "codeName": "Pie", + "id": "28", + "releaseDate": { + "day": 6, + "month": 8, + "year": 2018 + }, + "tags": [ + "default" + ], + "versionString": "9.x" + }, + { + "apiLevel": 29, + "codeName": "Q", + "id": "29", + "releaseDate": { + "day": 3, + "month": 9, + "year": 2019 + }, + "versionString": "10.x" + }, + { + "apiLevel": 30, + "codeName": "R", + "id": "30", + "releaseDate": { + "day": 3, + "month": 9, + "year": 2020 + }, + "versionString": "11" + }, + { + "apiLevel": 31, + "codeName": "S", + "id": "31", + "releaseDate": { + "day": 3, + "month": 9, + "year": 2021 + }, + "versionString": "12" + } +]