class Spaceship::Tunes::AppVersion

rubocop:disable Metrics/ClassLength
This can either be the live or the edit version retrieved via the app
Represents an editable version of an iTunes Connect Application

def candidate_builds

Returns an array of all builds that can be sent to review
def candidate_builds
  res = client.candidate_builds(self.application.apple_id, self.version_id)
  builds = []
  res.each do |attrs|
    next unless attrs["type"] == "BUILD" # I don't know if it can be something else.
    builds << Tunes::Build.factory(attrs)
  end
  return builds
end

def check_preview_screenshot_resolution(preview_screenshot_path, device)

ensure the specified preview screenshot has the expected resolution the specified target +device+
def check_preview_screenshot_resolution(preview_screenshot_path, device)
  is_portrait = Utilities.portrait?(preview_screenshot_path)
  expected_resolution = TunesClient.video_preview_resolution_for(device, is_portrait)
  actual_resolution = Utilities.resolution(preview_screenshot_path)
  orientation = is_portrait ? "portrait" : "landscape"
  raise "Invalid #{orientation} screenshot resolution for device #{device}. Should be #{expected_resolution}" unless (actual_resolution == expected_resolution)
end

def container_data_for_language_and_device(data_field, language, device)

def container_data_for_language_and_device(data_field, language, device)
  raise "#{device} isn't a valid device name" unless DeviceType.exists?(device)
  languages = raw_data_details.select { |d| d["language"] == language }
  # IDEA: better error for non existing language
  raise "#{language} isn't an activated language" unless languages.count > 0
  lang_details = languages[0]
  devices_details = lang_details[data_field]["value"]
  raise "Unexpected state: missing device details for #{device}" unless devices_details.key? device
  devices_details[device]
end

def create_languages(languages)

Important: Due to a bug you have to fetch the `edit_version` again, as it doesn't get refreshed immediately
This will create the new language if it's not available yet and do nothing if everything's there
You should call this method before accessing the name, description and other localized values
Call this method to make sure the given languages are available for this app
def create_languages(languages)
  languages = [languages] if languages.kind_of?(String)
  raise "Please pass an array" unless languages.kind_of? Array
  copy_from = self.languages.find { |a| a['language'] == 'en-US' } || self.languages.first
  languages.each do |language|
    # First, see if it's already available
    found = self.languages.find do |local|
      local['language'] == language
    end
    next if found
    new_language = copy_from.clone
    new_language['language'] = language
    self.languages << new_language
  end
  nil
end

def factory(attrs)

This is used to create a new object based on the server response.
Create a new object based on a hash.
def factory(attrs)
  obj = self.new(attrs)
  obj.unfold_languages
  return obj
end

def find(application, app_id, is_live)

Parameters:
  • is_live (Boolean) --
  • app_id (String) -- The unique Apple ID of this app
  • application (Spaceship::Tunes::Application) -- The app this version is for
def find(application, app_id, is_live)
  attrs = client.app_version(app_id, is_live)
  return nil unless attrs
  attrs.merge!(application: application)
  attrs.merge!(is_live: is_live)
  return self.factory(attrs)
end

def is_live?

Returns:
  • (Bool) - Is that version currently available in the App Store?
def is_live?
  is_live
end

def raw_data_details

def raw_data_details
  raw_data["details"]["value"]
end

def release_on_approval

are not returned in the right format, e.g. boolean as string
These methods takes care of properly parsing values that
def release_on_approval
  super == 'true'
end

def save!

Push all changes that were made back to iTunes Connect
def save!
  client.update_app_version!(application.apple_id, self.version_id, raw_data)
end

def screenshots_data_for_language_and_device(language, device)

def screenshots_data_for_language_and_device(language, device)
  container_data_for_language_and_device("screenshots", language, device)
end

def select_build(build)

Don't forget to call save! after calling this method
You have to pass a build you got from - candidate_builds
Select a build to be submitted for Review.
def select_build(build)
  raw_data.set(['preReleaseBuildVersionString', 'value'], build.build_version)
  raw_data.set(['preReleaseBuildTrainVersionString'], build.train_version)
  raw_data.set(['preReleaseBuildUploadDate'], build.upload_date)
  true
end

def setup

Private methods
def setup
  # Properly parse the AppStatus
  status = raw_data['status']
  @app_status = Tunes::AppStatus.get_from_string(status)
  setup_large_app_icon
  setup_watch_app_icon
  setup_transit_app_file
  setup_screenshots
  setup_trailers
end

def setup_large_app_icon

def setup_large_app_icon
  large_app_icon = raw_data["largeAppIcon"]["value"]
  @large_app_icon = nil
  @large_app_icon = Tunes::AppImage.factory(large_app_icon) if large_app_icon
end

def setup_screenshots

def setup_screenshots
  @screenshots = {}
  raw_data_details.each do |row|
    # Now that's one language right here
    @screenshots[row['language']] = setup_screenshots_for(row)
  end
end

def setup_screenshots_for(row)

generates the nested data structure to represent screenshots
def setup_screenshots_for(row)
  screenshots = row.fetch("screenshots", {}).fetch("value", nil)
  return [] unless screenshots
  result = []
  screenshots.each do |device_type, value|
    value["value"].each do |screenshot|
      screenshot_data = screenshot["value"]
      data = {
          device_type: device_type,
          language: row["language"]
      }.merge(screenshot_data)
      result << Tunes::AppScreenshot.factory(data)
    end
  end
  return result
end

def setup_trailers

def setup_trailers
  @trailers = {}
  raw_data_details.each do |row|
    # Now that's one language right here
    @trailers[row["language"]] = setup_trailers_for(row)
  end
end

def setup_trailers_for(row)

generates the nested data structure to represent trailers
def setup_trailers_for(row)
  trailers = row.fetch("appTrailers", {}).fetch("value", nil)
  return [] unless trailers
  result = []
  trailers.each do |device_type, value|
    trailer_data = value["value"]
    next if trailer_data.nil?
    data = {
        device_type: device_type,
        language: row["language"]
    }.merge(trailer_data)
    result << Tunes::AppTrailer.factory(data)
  end
  return result
end

def setup_transit_app_file

def setup_transit_app_file
  transit_app_file = raw_data["transitAppFile"]["value"]
  @transit_app_file = nil
  @transit_app_file = Tunes::TransitAppFile.factory(transit_app_file) if transit_app_file
end

def setup_watch_app_icon

def setup_watch_app_icon
  watch_app_icon = raw_data["watchAppIcon"]["value"]
  @watch_app_icon = nil
  @watch_app_icon = Tunes::AppImage.factory(watch_app_icon) if watch_app_icon
end

def supports_apple_watch

def supports_apple_watch
  !super.nil?
end

def trailer_data_for_language_and_device(language, device)

def trailer_data_for_language_and_device(language, device)
  container_data_for_language_and_device("appTrailers", language, device)
end

def unfold_languages

Prefill name, keywords, etc...
def unfold_languages
  {
    keywords: :keywords,
    description: :description,
    supportURL: :support_url,
    marketingURL: :marketing_url,
    releaseNotes: :release_notes
  }.each do |json, attribute|
    instance_variable_set("@#{attribute}".to_sym, LanguageItem.new(json, languages))
  end
end

def update_rating(hash)

https://github.com/KrauseFx/deliver/blob/master/Reference.md
Available Values

})
'GAMBLING_CONTESTS' => 0
'UNRESTRICTED_WEB_ACCESS' => 0,
'MATURE_SUGGESTIVE' => 2,
'CARTOON_FANTASY_VIOLENCE' => 0,
v.update_rating({
Call it like this:
Set the age restriction rating
def update_rating(hash)
  raise "Must be a hash" unless hash.kind_of?(Hash)
  hash.each do |key, value|
    to_edit = self.raw_data['ratings']['nonBooleanDescriptors'].find do |current|
      current['name'].include?(key)
    end
    if to_edit
      to_set = "NONE" if value == 0
      to_set = "INFREQUENT_MILD" if value == 1
      to_set = "FREQUENT_INTENSE" if value == 2
      raise "Invalid value '#{value}' for '#{key}', must be 0-2" unless to_set
      to_edit['level'] = "ITC.apps.ratings.level.#{to_set}"
    else
      # Maybe it's a boolean descriptor?
      to_edit = self.raw_data['ratings']['booleanDescriptors'].find do |current|
        current['name'].include?(key)
      end
      if to_edit
        to_set = "NO"
        to_set = "YES" if value.to_i > 0
        to_edit['level'] = "ITC.apps.ratings.level.#{to_set}"
      else
        raise "Could not find option '#{key}' in the list of available options"
      end
    end
  end
  true
end

def upload_geojson!(geojson_path)

Parameters:
  • icon_path (String) -- : The path to the geojson file. Use nil to remove it
def upload_geojson!(geojson_path)
  unless geojson_path
    raw_data["transitAppFile"]["value"] = nil
    @transit_app_file = nil
    return
  end
  upload_file = UploadFile.from_path geojson_path
  geojson_data = client.upload_geojson(self, upload_file)
  @transit_app_file = Tunes::TransitAppFile.factory({}) if @transit_app_file.nil?
  @transit_app_file .url = nil # response.headers['Location']
  @transit_app_file.asset_token = geojson_data["token"]
  @transit_app_file.name = upload_file.file_name
  @transit_app_file.time_stamp = Time.now.to_i * 1000 # works without but...
end

def upload_large_icon!(icon_path)

Parameters:
  • icon_path (String) -- : The path to the icon. Use nil to remove it
def upload_large_icon!(icon_path)
  unless icon_path
    @large_app_icon.reset!
    return
  end
  upload_image = UploadFile.from_path icon_path
  image_data = client.upload_large_icon(self, upload_image)
  @large_app_icon.reset!({ asset_token: image_data['token'], original_file_name: upload_image.file_name })
end

def upload_screenshot!(screenshot_path, sort_order, language, device)

Parameters:
  • device (string) -- : The device for this screenshot
  • language (String) -- : The language for this screenshot
  • sort_order (Fixnum) -- : The sort_order, from 1 to 5
  • icon_path (String) -- : The path to the screenshot. Use nil to remove it
def upload_screenshot!(screenshot_path, sort_order, language, device)
  raise "sort_order must be positive" unless sort_order > 0
  raise "sort_order must not be > 5" if sort_order > 5
  # this will also check both language and device parameters
  device_lang_screenshots = screenshots_data_for_language_and_device(language, device)["value"]
  existing_sort_orders = device_lang_screenshots.map { |s| s["value"]["sortOrder"] }
  if screenshot_path # adding / replacing
    upload_file = UploadFile.from_path screenshot_path
    screenshot_data = client.upload_screenshot(self, upload_file, device)
    new_screenshot = {
        "value" => {
            "assetToken" => screenshot_data["token"],
            "sortOrder" => sort_order,
            "url" => nil,
            "thumbNailUrl" => nil,
            "originalFileName" => upload_file.file_name
        }
    }
    if existing_sort_orders.include?(sort_order) # replace
      device_lang_screenshots[existing_sort_orders.index(sort_order)] = new_screenshot
    else # add
      device_lang_screenshots << new_screenshot
    end
  else # removing
    raise "cannot remove screenshot with non existing sort_order" unless existing_sort_orders.include?(sort_order)
    device_lang_screenshots.delete_at(existing_sort_orders.index(sort_order))
  end
  setup_screenshots
end

def upload_trailer!(trailer_path, language, device, timestamp = "05.00", preview_image_path = nil)

Parameters:
  • timestamp (String) -- : The optional timestamp of the screenshot to grab
  • device (String) -- : The device for this screenshot
  • language (String) -- : The language for this screenshot
  • sort_order (Fixnum) -- : The sort_order, from 1 to 5
  • icon_path (String) -- : The path to the screenshot. Use nil to remove it
def upload_trailer!(trailer_path, language, device, timestamp = "05.00", preview_image_path = nil)
  raise "No app trailer supported for iphone35" if device == 'iphone35'
  device_lang_trailer = trailer_data_for_language_and_device(language, device)
  if trailer_path # adding / replacing trailer / replacing preview
    raise "Invalid timestamp #{timestamp}" if (timestamp =~ /^[0-9][0-9].[0-9][0-9]$/).nil?
    if preview_image_path
      check_preview_screenshot_resolution(preview_image_path, device)
      video_preview_path = preview_image_path
    else
      # IDEA: optimization, we could avoid fetching the screenshot if the timestamp hasn't changed
      video_preview_resolution = video_preview_resolution_for(device, trailer_path)
      video_preview_path = Utilities.grab_video_preview(trailer_path, timestamp, video_preview_resolution)
    end
    video_preview_file = UploadFile.from_path video_preview_path
    video_preview_data = client.upload_trailer_preview(self, video_preview_file)
    trailer = device_lang_trailer["value"]
    if trailer.nil? # add trailer
      upload_file = UploadFile.from_path trailer_path
      trailer_data = client.upload_trailer(self, upload_file)
      trailer_data = trailer_data['responses'][0]
      trailer = {
          "videoAssetToken" => trailer_data["token"],
          "descriptionXML" => trailer_data["descriptionDoc"],
          "contentType" => upload_file.content_type
      }
      device_lang_trailer["value"] = trailer
    end
    # add / update preview
    # different format required
    ts = "00:00:#{timestamp}"
    ts[8] = ':'
    trailer.merge!({
      "pictureAssetToken" => video_preview_data["token"],
      "previewFrameTimeCode" => "#{ts}",
      "isPortrait" => Utilities.portrait?(video_preview_path)
    })
  else # removing trailer
    raise "cannot remove non existing trailer" if device_lang_trailer["value"].nil?
    device_lang_trailer["value"] = nil
  end
  setup_trailers
end

def upload_watch_icon!(icon_path)

Parameters:
  • icon_path (String) -- : The path to the icon. Use nil to remove it
def upload_watch_icon!(icon_path)
  unless icon_path
    @watch_app_icon.reset!
    return
  end
  upload_image = UploadFile.from_path icon_path
  image_data = client.upload_watch_icon(self, upload_image)
  @watch_app_icon.reset!({ asset_token: image_data["token"], original_file_name: upload_image.file_name })
end

def url

Returns:
  • (String) - An URL to this specific resource. You can enter this URL into your browser
def url
  url = "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/904332168/ios/versioninfo/"
  url += "deliverable" if self.is_live?
  return url
end

def video_preview_resolution_for(device, video_path)

identify the required resolution for this particular video screenshot
def video_preview_resolution_for(device, video_path)
  is_portrait = Utilities.portrait?(video_path)
  TunesClient.video_preview_resolution_for(device, is_portrait)
end