class Deliver::UploadMetadata

upload description, rating, etc. rubocop:disable Metrics/ClassLength

Constants

ALL_META_SUB_DIRS
LOCALISED_APP_VALUES

Localised app details values

LOCALISED_LIVE_VALUES

Localized app details values, that are editable in live state

LOCALISED_VERSION_VALUES

All the localised values attached to the version

NON_LOCALISED_APP_VALUES

Non localized app details values

NON_LOCALISED_LIVE_VALUES

Non localized app details values, that are editable in live state

NON_LOCALISED_VERSION_VALUES

Everything attached to the version but not being localised

REVIEW_INFORMATION_DIR

Directory name it contains review information

REVIEW_INFORMATION_VALUES
REVIEW_INFORMATION_VALUES_LEGACY

Review information values

TRADE_REPRESENTATIVE_CONTACT_INFORMATION_DIR

Directory name it contains trade representative contact information

Public Instance Methods

assign_defaults(options) click to toggle source

If the user is using the 'default' language, then assign values where they are needed

# File deliver/lib/deliver/upload_metadata.rb, line 361
def assign_defaults(options)
  # Normalizes languages keys from symbols to strings
  normalize_language_keys(options)

  # Build a complete list of the required languages
  enabled_languages = detect_languages(options)

  # Get all languages used in existing settings
  (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key|
    current = options[key]
    next unless current && current.kind_of?(Hash)
    current.each do |language, value|
      enabled_languages << language unless enabled_languages.include?(language)
    end
  end

  # Check folder list (an empty folder signifies a language is required)
  ignore_validation = options[:ignore_language_directory_validation]
  Loader.language_folders(options[:metadata_path], ignore_validation).each do |lang_folder|
    enabled_languages << lang_folder.basename unless enabled_languages.include?(lang_folder.basename)
  end

  return unless enabled_languages.include?("default")
  UI.message("Detected languages: " + enabled_languages.to_s)

  (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key|
    current = options[key]
    next unless current && current.kind_of?(Hash)

    default = current["default"]
    next if default.nil?

    enabled_languages.each do |language|
      value = current[language]
      next unless value.nil?

      current[language] = default
    end
    current.delete("default")
  end
end
convert_ms_to_iso8601(time_in_ms) click to toggle source

rubocop:enable Metrics/PerceivedComplexity

# File deliver/lib/deliver/upload_metadata.rb, line 350
def convert_ms_to_iso8601(time_in_ms)
  time_in_s = time_in_ms / 1000

  # Remove minutes and seconds (whole hour)
  seconds_in_hour = 60 * 60
  time_in_s_to_hour = (time_in_s / seconds_in_hour).to_i * seconds_in_hour

  return Time.at(time_in_s_to_hour).utc.strftime("%Y-%m-%dT%H:%M:%S%:z")
end
detect_languages(options) click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 403
def detect_languages(options)
  # Build a complete list of the required languages
  enabled_languages = options[:languages] || []

  # Get all languages used in existing settings
  (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key|
    current = options[key]
    next unless current && current.kind_of?(Hash)
    current.each do |language, value|
      enabled_languages << language unless enabled_languages.include?(language)
    end
  end

  # Check folder list (an empty folder signifies a language is required)
  ignore_validation = options[:ignore_language_directory_validation]
  Loader.language_folders(options[:metadata_path], ignore_validation).each do |lang_folder|
    enabled_languages << lang_folder.basename unless enabled_languages.include?(lang_folder.basename)
  end

  # Mapping to strings because :default symbol can be passed in
  enabled_languages
    .map(&:to_s)
    .uniq
end
fetch_edit_app_info(app, wait_time: 10) click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 434
def fetch_edit_app_info(app, wait_time: 10)
  retry_if_nil("Cannot find edit app info", wait_time: wait_time) do
    app.fetch_edit_app_info
  end
end
fetch_edit_app_store_version(app, platform, wait_time: 10) click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 428
def fetch_edit_app_store_version(app, platform, wait_time: 10)
  retry_if_nil("Cannot find edit app store version", wait_time: wait_time) do
    app.get_edit_app_store_version(platform: platform)
  end
end
load_from_filesystem(options) click to toggle source

Loads the metadata files and stores them into the options object

# File deliver/lib/deliver/upload_metadata.rb, line 524
def load_from_filesystem(options)
  return if options[:skip_metadata]

  # Load localised data
  ignore_validation = options[:ignore_language_directory_validation]
  Loader.language_folders(options[:metadata_path], ignore_validation).each do |lang_folder|
    (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key|
      path = File.join(lang_folder.path, "#{key}.txt")
      next unless File.exist?(path)

      UI.message("Loading '#{path}'...")
      options[key] ||= {}
      options[key][lang_folder.basename] ||= File.read(path)
    end
  end

  # Load non localised data
  (NON_LOCALISED_VERSION_VALUES.keys + NON_LOCALISED_APP_VALUES.keys).each do |key|
    path = File.join(options[:metadata_path], "#{key}.txt")
    next unless File.exist?(path)

    UI.message("Loading '#{path}'...")
    options[key] ||= File.read(path)
  end

  # Load review information
  # This is used to find the file path for both new and legacy review information filenames
  resolve_review_info_path = lambda do |option_name|
    path = File.join(options[:metadata_path], REVIEW_INFORMATION_DIR, "#{option_name}.txt")
    return nil unless File.exist?(path)
    return nil if options[:app_review_information][option_name].to_s.length > 0

    UI.message("Loading '#{path}'...")
    return path
  end

  # First try and load review information from legacy filenames
  options[:app_review_information] ||= {}
  REVIEW_INFORMATION_VALUES_LEGACY.each do |legacy_option_name, option_name|
    path = resolve_review_info_path.call(legacy_option_name)
    next if path.nil?
    options[:app_review_information][option_name] ||= File.read(path)

    UI.deprecated("Review rating option '#{legacy_option_name}' from iTunesConnect has been deprecated. Please replace with '#{option_name}'")
  end

  # Then load review information from new App Store Connect filenames
  REVIEW_INFORMATION_VALUES.keys.each do |option_name|
    path = resolve_review_info_path.call(option_name)
    next if path.nil?
    options[:app_review_information][option_name] ||= File.read(path)
  end
end
retry_if_nil(message, tries: 5, wait_time: 10) { || ... } click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 440
def retry_if_nil(message, tries: 5, wait_time: 10)
  loop do
    tries -= 1

    value = yield
    return value if value

    UI.message("#{message}... Retrying after #{wait_time} seconds (remaining: #{tries})")
    sleep(wait_time)

    return nil if tries.zero?
  end
end
upload(options) click to toggle source

Make sure to call `load_from_filesystem` before calling upload

# File deliver/lib/deliver/upload_metadata.rb, line 82
def upload(options)
  return if options[:skip_metadata]

  app = Deliver.cache[:app]

  platform = Spaceship::ConnectAPI::Platform.map(options[:platform])

  enabled_languages = detect_languages(options)

  app_store_version_localizations = verify_available_version_languages!(options, app, enabled_languages) unless options[:edit_live]
  app_info_localizations = verify_available_info_languages!(options, app, enabled_languages) unless options[:edit_live]

  if options[:edit_live]
    # not all values are editable when using live_version
    version = app.get_live_app_store_version(platform: platform)
    localised_options = LOCALISED_LIVE_VALUES
    non_localised_options = NON_LOCALISED_LIVE_VALUES

    if version.nil?
      UI.message("Couldn't find live version, editing the current version on App Store Connect instead")
      version = fetch_edit_app_store_version(app, platform)
      # we don't want to update the localised_options and non_localised_options
      # as we also check for `options[:edit_live]` at other areas in the code
      # by not touching those 2 variables, deliver is more consistent with what the option says
      # in the documentation
    else
      UI.message("Found live version")
    end
  else
    version = fetch_edit_app_store_version(app, platform)
    localised_options = (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys)
    non_localised_options = NON_LOCALISED_VERSION_VALUES.keys
  end

  # Needed for to filter out release notes from being sent up
  number_of_versions = Spaceship::ConnectAPI.get_app_store_versions(
    app_id: app.id,
    filter: { platform: platform },
    limit: 2
  ).count
  is_first_version = number_of_versions == 1
  UI.verbose("Version '#{version.version_string}' is the first version on App Store Connect") if is_first_version

  UI.important("Will begin uploading metadata for '#{version.version_string}' on App Store Connect")

  localized_version_attributes_by_locale = {}
  localized_info_attributes_by_locale = {}

  localised_options.each do |key|
    current = options[key]
    next unless current

    unless current.kind_of?(Hash)
      UI.error("Error with provided '#{key}'. Must be a hash, the key being the language.")
      next
    end

    if key == :release_notes && is_first_version
      UI.error("Skipping 'release_notes'... this is the first version of the app")
      next
    end

    current.each do |language, value|
      next unless value.to_s.length > 0
      strip_value = value.to_s.strip

      if LOCALISED_VERSION_VALUES.include?(key) && !strip_value.empty?
        attribute_name = LOCALISED_VERSION_VALUES[key]

        localized_version_attributes_by_locale[language] ||= {}
        localized_version_attributes_by_locale[language][attribute_name] = strip_value
      end

      next unless LOCALISED_APP_VALUES.include?(key) && !strip_value.empty?
      attribute_name = LOCALISED_APP_VALUES[key]

      localized_info_attributes_by_locale[language] ||= {}
      localized_info_attributes_by_locale[language][attribute_name] = strip_value
    end
  end

  non_localized_version_attributes = {}
  non_localised_options.each do |key|
    strip_value = options[key].to_s.strip
    next unless strip_value.to_s.length > 0

    if NON_LOCALISED_VERSION_VALUES.include?(key) && !strip_value.empty?
      attribute_name = NON_LOCALISED_VERSION_VALUES[key]
      non_localized_version_attributes[attribute_name] = strip_value
    end
  end

  release_type = if options[:auto_release_date]
                   # Convert time format to 2020-06-17T12:00:00-07:00
                   time_in_ms = options[:auto_release_date]
                   date = convert_ms_to_iso8601(time_in_ms)

                   non_localized_version_attributes['earliestReleaseDate'] = date
                   Spaceship::ConnectAPI::AppStoreVersion::ReleaseType::SCHEDULED
                 elsif options[:automatic_release] == true
                   Spaceship::ConnectAPI::AppStoreVersion::ReleaseType::AFTER_APPROVAL
                 elsif options[:automatic_release] == false
                   Spaceship::ConnectAPI::AppStoreVersion::ReleaseType::MANUAL
                 end
  if release_type.nil?
    UI.important("Release type will not be set because neither `automatic_release` nor `auto_release_date` were provided. Please explicitly set one of these options if you need a release type set")
  else
    non_localized_version_attributes['releaseType'] = release_type
  end

  # Update app store version
  # This needs to happen before updating localizations (https://openradar.appspot.com/radar?id=4925914991296512)
  #
  # Adding some sleeps because the API will sometimes be in a state where releaseType can't be modified
  #   https://github.com/fastlane/fastlane/issues/16911
  UI.message("Uploading metadata to App Store Connect for version")
  sleep(2)
  version.update(attributes: non_localized_version_attributes)
  sleep(1)

  # Update app store version localizations
  store_version_worker = FastlaneCore::QueueWorker.new do |app_store_version_localization|
    attributes = localized_version_attributes_by_locale[app_store_version_localization.locale]
    if attributes
      UI.message("Uploading metadata to App Store Connect for localized version '#{app_store_version_localization.locale}'")
      app_store_version_localization.update(attributes: attributes)
    end
  end
  store_version_worker.batch_enqueue(app_store_version_localizations)
  store_version_worker.start

  # Update app info localizations
  app_info_worker = FastlaneCore::QueueWorker.new do |app_info_localization|
    attributes = localized_info_attributes_by_locale[app_info_localization.locale]
    if attributes
      UI.message("Uploading metadata to App Store Connect for localized info '#{app_info_localization.locale}'")
      app_info_localization.update(attributes: attributes)
    end
  end
  app_info_worker.batch_enqueue(app_info_localizations)
  app_info_worker.start

  # Update categories
  app_info = fetch_edit_app_info(app)
  if app_info
    category_id_map = {}

    primary_category = options[:primary_category].to_s.strip
    secondary_category = options[:secondary_category].to_s.strip
    primary_first_sub_category = options[:primary_first_sub_category].to_s.strip
    primary_second_sub_category = options[:primary_second_sub_category].to_s.strip
    secondary_first_sub_category = options[:secondary_first_sub_category].to_s.strip
    secondary_second_sub_category = options[:secondary_second_sub_category].to_s.strip

    mapped_values = {}

    # Only update primary and secondar category if explicitly set
    unless primary_category.empty?
      mapped = Spaceship::ConnectAPI::AppCategory.map_category_from_itc(
        primary_category
      )

      mapped_values[primary_category] = mapped
      category_id_map[:primary_category_id] = mapped
    end
    unless secondary_category.empty?
      mapped = Spaceship::ConnectAPI::AppCategory.map_category_from_itc(
        secondary_category
      )

      mapped_values[secondary_category] = mapped
      category_id_map[:secondary_category_id] = mapped
    end

    # Only set if primary category is going to be set
    unless primary_category.empty?
      mapped = Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc(
        primary_first_sub_category
      )

      mapped_values[primary_first_sub_category] = mapped
      category_id_map[:primary_subcategory_one_id] = mapped
    end
    unless primary_category.empty?
      mapped = Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc(
        primary_second_sub_category
      )

      mapped_values[primary_second_sub_category] = mapped
      category_id_map[:primary_subcategory_two_id] = mapped
    end

    # Only set if secondary category is going to be set
    unless secondary_category.empty?
      mapped = Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc(
        secondary_first_sub_category
      )

      mapped_values[secondary_first_sub_category] = mapped
      category_id_map[:secondary_subcategory_one_id] = mapped
    end
    unless secondary_category.empty?
      mapped = Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc(
        secondary_second_sub_category
      )

      mapped_values[secondary_second_sub_category] = mapped
      category_id_map[:secondary_subcategory_two_id] = mapped
    end

    # Print deprecation warnings if category was mapped
    has_mapped_values = false
    mapped_values.each do |k, v|
      next if k.nil? || v.nil?
      next if k == v
      has_mapped_values = true
      UI.deprecated("Category '#{k}' from iTunesConnect has been deprecated. Please replace with '#{v}'")
    end
    UI.deprecated("You can find more info at https://docs.fastlane.tools/actions/deliver/#reference") if has_mapped_values

    app_info.update_categories(category_id_map: category_id_map)
  end

  # Update phased release
  unless options[:phased_release].nil?
    phased_release = begin
                       version.fetch_app_store_version_phased_release
                     rescue
                       nil
                     end # returns no data error so need to rescue
    if !!options[:phased_release]
      unless phased_release
        UI.message("Creating phased release on App Store Connect")
        version.create_app_store_version_phased_release(attributes: {
          phasedReleaseState: Spaceship::ConnectAPI::AppStoreVersionPhasedRelease::PhasedReleaseState::INACTIVE
        })
      end
    elsif phased_release
      UI.message("Removing phased release on App Store Connect")
      phased_release.delete!
    end
  end

  # Update rating reset
  unless options[:reset_ratings].nil?
    reset_rating_request = begin
                             version.fetch_reset_ratings_request
                           rescue
                             nil
                           end # returns no data error so need to rescue
    if !!options[:reset_ratings]
      unless reset_rating_request
        UI.message("Creating reset ratings request on App Store Connect")
        version.create_reset_ratings_request
      end
    elsif reset_rating_request
      UI.message("Removing reset ratings request on App Store Connect")
      reset_rating_request.delete!
    end
  end

  set_review_information(version, options)
  set_review_attachment_file(version, options)
  set_app_rating(app_info, options)
end
verify_available_info_languages!(options, app, languages) click to toggle source

Finding languages to enable

# File deliver/lib/deliver/upload_metadata.rb, line 455
def verify_available_info_languages!(options, app, languages)
  app_info = fetch_edit_app_info(app)

  unless app_info
    UI.user_error!("Cannot update languages - could not find an editable info")
    return
  end

  localizations = app_info.get_app_info_localizations

  languages = (languages || []).reject { |lang| lang == "default" }
  locales_to_enable = languages - localizations.map(&:locale)

  if locales_to_enable.count > 0
    lng_text = "language"
    lng_text += "s" if locales_to_enable.count != 1
    Helper.show_loading_indicator("Activating info #{lng_text} #{locales_to_enable.join(', ')}...")

    locales_to_enable.each do |locale|
      app_info.create_app_info_localization(attributes: {
        locale: locale
      })
    end

    Helper.hide_loading_indicator

    # Refresh version localizations
    localizations = app_info.get_app_info_localizations
  end

  return localizations
end
verify_available_version_languages!(options, app, languages) click to toggle source

Finding languages to enable

# File deliver/lib/deliver/upload_metadata.rb, line 489
def verify_available_version_languages!(options, app, languages)
  platform = Spaceship::ConnectAPI::Platform.map(options[:platform])
  version = fetch_edit_app_store_version(app, platform)

  unless version
    UI.user_error!("Cannot update languages - could not find an editable version for '#{platform}'")
    return
  end

  localizations = version.get_app_store_version_localizations

  languages = (languages || []).reject { |lang| lang == "default" }
  locales_to_enable = languages - localizations.map(&:locale)

  if locales_to_enable.count > 0
    lng_text = "language"
    lng_text += "s" if locales_to_enable.count != 1
    Helper.show_loading_indicator("Activating version #{lng_text} #{locales_to_enable.join(', ')}...")

    locales_to_enable.each do |locale|
      version.create_app_store_version_localization(attributes: {
        locale: locale
      })
    end

    Helper.hide_loading_indicator

    # Refresh version localizations
    localizations = version.get_app_store_version_localizations
  end

  return localizations
end

Private Instance Methods

normalize_language_keys(options) click to toggle source

Normalizes languages keys from symbols to strings

# File deliver/lib/deliver/upload_metadata.rb, line 581
def normalize_language_keys(options)
  (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key|
    current = options[key]
    next unless current && current.kind_of?(Hash)

    current.keys.each do |language|
      current[language.to_s] = current.delete(language)
    end
  end

  options
end
set_app_rating(app_info, options) click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 645
def set_app_rating(app_info, options)
  return unless options[:app_rating_config_path]

  require 'json'
  begin
    json = JSON.parse(File.read(options[:app_rating_config_path]))
  rescue => ex
    UI.error(ex.to_s)
    UI.user_error!("Error parsing JSON file at path '#{options[:app_rating_config_path]}'")
  end
  UI.message("Setting the app's age rating...")

  # Maping from legacy ITC values to App Store Connect Values
  mapped_values = {}
  attributes = {}
  json.each do |k, v|
    new_key = Spaceship::ConnectAPI::AgeRatingDeclaration.map_key_from_itc(k)
    new_value = Spaceship::ConnectAPI::AgeRatingDeclaration.map_value_from_itc(new_key, v)

    mapped_values[k] = new_key
    mapped_values[v] = new_value

    attributes[new_key] = new_value
  end

  # Print deprecation warnings if category was mapped
  has_mapped_values = false
  mapped_values.each do |k, v|
    next if k.nil? || v.nil?
    next if k == v
    has_mapped_values = true
    UI.deprecated("Age rating '#{k}' from iTunesConnect has been deprecated. Please replace with '#{v}'")
  end

  # Handle App Store Connect deprecation/migrations of keys/values if possible
  attributes, deprecation_messages, errors = Spaceship::ConnectAPI::AgeRatingDeclaration.map_deprecation_if_possible(attributes)
  deprecation_messages.each do |message|
    UI.deprecated(message)
  end

  unless errors.empty?
    errors.each do |error|
      UI.error(error)
    end
    UI.user_error!("There are Age Rating deprecation errors that cannot be solved automatically... Please apply any fixes and try again")
  end

  UI.deprecated("You can find more info at https://docs.fastlane.tools/actions/deliver/#reference") if has_mapped_values || !deprecation_messages.empty?

  age_rating_declaration = app_info.fetch_age_rating_declaration
  age_rating_declaration.update(attributes: attributes)
end
set_review_attachment_file(version, options) click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 627
def set_review_attachment_file(version, options)
  app_store_review_detail = version.fetch_app_store_review_detail
  app_store_review_attachments = app_store_review_detail.app_store_review_attachments || []

  if options[:app_review_attachment_file]
    app_store_review_attachments.each do |app_store_review_attachment|
      UI.message("Removing previous review attachment file from App Store Connect")
      app_store_review_attachment.delete!
    end

    UI.message("Uploading review attachment file to App Store Connect")
    app_store_review_detail.upload_attachment(path: options[:app_review_attachment_file])
  else
    app_store_review_attachments.each(&:delete!)
    UI.message("Removing review attachment file to App Store Connect") unless app_store_review_attachments.empty?
  end
end
set_review_information(version, options) click to toggle source
# File deliver/lib/deliver/upload_metadata.rb, line 594
def set_review_information(version, options)
  info = options[:app_review_information]
  return if info.nil? || info.empty?

  info = info.transform_keys(&:to_sym)
  UI.user_error!("`app_review_information` must be a hash", show_github_issues: true) unless info.kind_of?(Hash)

  attributes = {}
  REVIEW_INFORMATION_VALUES.each do |key, attribute_name|
    strip_value = info[key].to_s.strip
    attributes[attribute_name] = strip_value unless strip_value.empty?
  end

  if !attributes["demo_account_name"].to_s.empty? && !attributes["demo_account_password"].to_s.empty?
    attributes["demo_account_required"] = true
  else
    attributes["demo_account_required"] = false
  end

  UI.message("Uploading app review information to App Store Connect")
  app_store_review_detail = begin
                              version.fetch_app_store_review_detail
                            rescue => error
                              UI.error("Error fetching app store review detail - #{error.message}")
                              nil
                            end # errors if doesn't exist
  if app_store_review_detail
    app_store_review_detail.update(attributes: attributes)
  else
    version.create_app_store_review_detail(attributes: attributes)
  end
end