require_relative 'tunes_client' require_relative 'app_trailer' require_relative 'app_screenshot' require_relative 'app_image' require_relative 'display_family' require_relative 'app_version_generated_promocodes' require_relative 'language_item' require_relative 'transit_app_file' require_relative 'build' require_relative 'app_status' require_relative 'app_review_attachment' module Spaceship module Tunes # Represents an editable version of an App Store Connect Application # This can either be the live or the edit version retrieved via the app # rubocop:disable Metrics/ClassLength class AppVersion < TunesBase # @return (Spaceship::Tunes::Application) A reference to the application # this version is for attr_accessor :application # @return (String) The platform value of this version. attr_accessor :platform # @return (String) The version number of this version attr_accessor :version # @return (String) The copyright information of this app attr_accessor :copyright # @return (String) The appType number of this version attr_accessor :app_type # @return (Spaceship::Tunes::AppStatus) What's the current status of this app # e.g. Waiting for Review, Ready for Sale, ... attr_reader :app_status # @return (Bool) Is that the version that's currently available in the App Store? attr_accessor :is_live # @return (String) App Status (e.g. 'readyForSale'). You should use `app_status` instead attr_accessor :raw_status # @return (String) Build Version attr_accessor :build_version # @return (Bool) attr_accessor :can_reject_version # @return (Bool) attr_accessor :can_prepare_for_upload # @return (Bool) attr_accessor :can_send_version_live # @return (Bool) Should the app automatically be released once it's approved? attr_accessor :release_on_approval # @return (Fixnum) Milliseconds for releasing in GMT (e.g. 1480435200000 = Tue, 29 Nov 2016 16:00:00 GMT). # Use nil to unset. Setting this will supercede the release_on_approval field, so this field must be nil # for release_on_approval to be used. attr_accessor :auto_release_date # @return (Bool) Should the rating of the app be reset? attr_accessor :ratings_reset # @return (Bool) attr_accessor :can_beta_test # @return (Bool) Does the binary contain a watch binary? attr_accessor :supports_apple_watch # @return (Spaceship::Tunes::AppImage) the structure containing information about the large app icon (1024x1024) attr_accessor :large_app_icon # @return (Spaceship::Tunes::AppImage) the structure containing information about the large watch icon (1024x1024) attr_accessor :watch_app_icon # @return (Integer) a unqiue ID for this version generated by App Store Connect attr_accessor :version_id # @return (Spaceship::Tunes::AppReviewAttachment) the structure containing information about the review attachment file attr_accessor :review_attachment_file #### # GeoJson #### # @return (Spaceship::Tunes::TransitAppFile) the structure containing information about the geo json. Can be nil attr_accessor :transit_app_file #### # Trade Representative Contact Information #### # @return (String) Trade Representative Contact Information Trade Name. This attribute isn't editable attr_accessor :trade_representative_trade_name # @return (String) Trade Representative Contact Information First Name attr_accessor :trade_representative_first_name # @return (String) Trade Representative Contact Information Last Name attr_accessor :trade_representative_last_name # @return (String) Trade Representative Contact Information Address Line 1 attr_accessor :trade_representative_address_line_1 # @return (String) Trade Representative Contact Information Address Line 2 attr_accessor :trade_representative_address_line_2 # @return (String) Trade Representative Contact Information Address Line 3 attr_accessor :trade_representative_address_line_3 # @return (String) Trade Representative Contact Information City Name attr_accessor :trade_representative_city_name # @return (String) Trade Representative Contact Information State attr_accessor :trade_representative_state # @return (String) Trade Representative Contact Information Country attr_accessor :trade_representative_country # @return (String) Trade Representative Contact Information Postal Code attr_accessor :trade_representative_postal_code # @return (String) Trade Representative Contact Information Phone Number attr_accessor :trade_representative_phone_number # @return (String) Trade Representative Contact Information Email Address attr_accessor :trade_representative_email # @return (Boolean) Display Trade Representative Contact Information on the Korean App Store or not attr_accessor :trade_representative_is_displayed_on_app_store #### # App Review Information #### # @return (String) App Review Information First Name attr_accessor :review_first_name # @return (String) App Review Information Last Name attr_accessor :review_last_name # @return (String) App Review Information Phone Number attr_accessor :review_phone_number # @return (String) App Review Information Email Address attr_accessor :review_email # @return (Boolean) The checkbox that indiciates if a demo account # is needed. Is set automatically depending on if a user and pass # are set attr_reader :review_user_needed # @return (String) App Review Information Demo Account User Name attr_accessor :review_demo_user # @return (String) App Review Information Demo Account Password attr_accessor :review_demo_password # @return (String) App Review Information Notes attr_accessor :review_notes #### # Localized values #### # @return (Array) Raw access the all available languages. You shouldn't use it probably attr_accessor :languages # @return (Hash) A hash representing the keywords in all languages attr_reader :keywords # @return (Hash) A hash representing the promotionalText in all languages attr_reader :promotional_text # @return (Hash) A hash representing the description in all languages attr_reader :description # @return (Hash) The changelog attr_reader :release_notes # @return (Hash) A hash representing the support url in all languages attr_reader :support_url # @return (Hash) A hash representing the marketing url in all languages attr_reader :marketing_url # @return (Hash) Represents the screenshots of this app version (read-only) attr_reader :screenshots # @return (Hash) Represents the trailers of this app version (read-only) attr_reader :trailers # @return (Hash) A hash representing all in-app purchases that can get submitted with this version attr_reader :in_app_purchases # @return (Hash) Represents the phased_release hash (read-only) # For now, please use the `toggle_phased_release` method and call `.save!` # as the API will probably change in the future attr_reader :phased_release # Currently phased_release doesn't seem to have all the features enabled # # => {"state"=>{"value"=>"NOT_STARTED", "isEditable"=>true, "isRequired"=>false, "errorKeys"=>nil}, # "startDate"=>nil, # "lastPaused"=>nil, # "pausedDuration"=>nil, # "totalPauseDays"=>30, # "currentDayNumber"=>nil, # "dayPercentageMap"=>{"1"=>1, "2"=>2, "3"=>5, "4"=>10, "5"=>20, "6"=>50, "7"=>100}, # "isEnabled"=>true} # def toggle_phased_release(enabled: false) state = (enabled ? "INACTIVE" : "NOT_STARTED") self.phased_release["state"]["value"] = state end attr_mapping({ 'appType' => :app_type, 'platform' => :platform, 'canBetaTest' => :can_beta_test, 'canPrepareForUpload' => :can_prepare_for_upload, 'canRejectVersion' => :can_reject_version, 'canSendVersionLive' => :can_send_version_live, 'copyright.value' => :copyright, 'details.value' => :languages, 'largeAppIcon.value.originalFileName' => :app_icon_original_name, 'largeAppIcon.value.url' => :app_icon_url, 'releaseOnApproval.value' => :release_on_approval, 'autoReleaseDate.value' => :auto_release_date, 'ratingsReset.value' => :ratings_reset, 'status' => :raw_status, 'preReleaseBuild.buildVersion' => :build_version, 'supportsAppleWatch' => :supports_apple_watch, 'versionId' => :version_id, 'version.value' => :version, 'submittableAddOns.value' => :in_app_purchases, 'phasedRelease' => :phased_release, # GeoJson # 'transitAppFile.value' => :transit_app_file # Trade Representative Contact Information 'appStoreInfo.tradeName.value' => :trade_representative_trade_name, 'appStoreInfo.firstName.value' => :trade_representative_first_name, 'appStoreInfo.lastName.value' => :trade_representative_last_name, 'appStoreInfo.addressLine1.value' => :trade_representative_address_line_1, 'appStoreInfo.addressLine2.value' => :trade_representative_address_line_2, 'appStoreInfo.addressLine3.value' => :trade_representative_address_line_3, 'appStoreInfo.cityName.value' => :trade_representative_city_name, 'appStoreInfo.state.value' => :trade_representative_state, 'appStoreInfo.country.value' => :trade_representative_country, 'appStoreInfo.postalCode.value' => :trade_representative_postal_code, 'appStoreInfo.phoneNumber.value' => :trade_representative_phone_number, 'appStoreInfo.emailAddress.value' => :trade_representative_email, 'appStoreInfo.shouldDisplayInStore.value' => :trade_representative_is_displayed_on_app_store, # App Review Information 'appReviewInfo.firstName.value' => :review_first_name, 'appReviewInfo.lastName.value' => :review_last_name, 'appReviewInfo.phoneNumber.value' => :review_phone_number, 'appReviewInfo.emailAddress.value' => :review_email, 'appReviewInfo.reviewNotes.value' => :review_notes, 'appReviewInfo.accountRequired.value' => :review_user_needed, 'appReviewInfo.userName.value' => :review_demo_user, 'appReviewInfo.password.value' => :review_demo_password }) class << self # Create a new object based on a hash. # This is used to create a new object based on the server response. def factory(attrs) obj = self.new(attrs) obj.unfold_languages return obj end # @param application (Spaceship::Tunes::Application) The app this version is for # @param app_id (String) The unique Apple ID of this app # @param is_live (Boolean) def find(application, app_id, is_live, platform: nil) # we only support applications raise "We do not support BUNDLE types right now" if application.type == 'BUNDLE' # too bad the "id" field is empty, it forces us to make more requests to the server # these could also be cached attrs = client.app_version(app_id, is_live, platform: platform) return nil unless attrs attrs[:application] = application attrs[:is_live] = is_live return self.factory(attrs) end end # @return (Bool) Is that version currently available in the App Store? def is_live? is_live end def review_user_needed (self.review_demo_user.to_s + self.review_demo_password.to_s).length > 0 end # Call this method to make sure the given languages are available for this app # You should call this method before accessing the name, description and other localized values # This will create the new language if it's not available yet and do nothing if everything's there # Important: Due to a bug you have to fetch the `edit_version` again, as it doesn't get refreshed immediately 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 current_build_number if self.is_live? build_version else if candidate_builds.length > 0 candidate_builds.sort_by(&:upload_date).last.build_version end end end # 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. attrs[:apple_id] = self.application.apple_id builds << Tunes::Build.factory(attrs) end return builds end # Select a build to be submitted for Review. # You have to pass a build you got from - candidate_builds # Don't forget to call save! after calling this method 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 # Set the age restriction rating # Call it like this: # v.update_rating({ # 'CARTOON_FANTASY_VIOLENCE' => 0, # 'MATURE_SUGGESTIVE' => 2, # 'UNRESTRICTED_WEB_ACCESS' => 0, # 'GAMBLING_CONTESTS' => 0 # }) # # Available Values # https://docs.fastlane.tools/actions/deliver/#reference 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 # Push all changes that were made back to App Store Connect def save! client.update_app_version!(application.apple_id, self.version_id, raw_data) end # @return (String) An URL to this specific resource. You can enter this URL into your browser def url url = "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/ng/app/#{application.apple_id}/#{self.platform}/versioninfo/" url += "deliverable" if self.is_live? return url end # Private methods def setup status = raw_data['status'] @app_status = Tunes::AppStatus.get_from_string(status) setup_large_app_icon setup_watch_app_icon setup_review_attachment_file if supports_review_attachment_file? setup_transit_app_file if supports_app_transit? setup_screenshots setup_trailers end # This method will generate the required keys/values # for App Store Connect to validate the uploaded image def generate_image_metadata(image_data, original_file_name) { assetToken: image_data["token"], originalFileName: original_file_name, size: image_data["length"], height: image_data["height"], width: image_data["width"], checksum: image_data["md5"] } end # This method will generate the required keys/values # for App Store Connect to validate the review attachment file def generate_review_attachment_file(review_attachment_data, review_attachment_file) { assetToken: review_attachment_data["token"], name: File.basename(review_attachment_file), fileType: Utilities.content_type(review_attachment_file), url: nil } end # Uploads or removes the large icon # @param 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) raw_data["largeAppIcon"]["value"] = generate_image_metadata(image_data, upload_image.file_name) end # Uploads or removes the watch icon # @param 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) raw_data["watchAppIcon"]["value"] = generate_image_metadata(image_data, upload_image.file_name) end # Uploads or removes the transit app file # @param 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 # Uploads or removes a screenshot # @param icon_path (String): The path to the screenshot. Use nil to remove it # @param sort_order (Fixnum): The sort_order, from 1 to 5 # @param language (String): The language for this screenshot # @param device (string): The device for this screenshot # @param is_messages (Bool): True if the screenshot is for iMessage def upload_screenshot!(screenshot_path, sort_order, language, device, is_messages) raise "sort_order must be higher than 0" unless sort_order > 0 raise "sort_order must not be > 10" if sort_order > 10 # this will also check both language and device parameters device_lang_screenshots = screenshots_data_for_language_and_device(language, device, is_messages)["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, is_messages) # Since October 2016 we also need to pass the size, height, width and checksum # otherwise App Store Connect validation will fail at a later point new_screenshot = { "value" => { "assetToken" => screenshot_data["token"], "sortOrder" => sort_order, "originalFileName" => upload_file.file_name, "size" => screenshot_data["length"], "height" => screenshot_data["height"], "width" => screenshot_data["width"], "checksum" => screenshot_data["md5"] } } # We disable "scaling" for this device type / language combination # We only set this, if we actually successfully uploaded a new screenshot # for this device / language combination # if this value is not set, iTC will fallback to another device type for screenshots language_details = raw_data_details.find { |d| d["language"] == language }["displayFamilies"]["value"] device_language_details = language_details.find { |display_family| display_family['name'] == device } scaled_key = is_messages ? "messagesScaled" : "scaled" device_language_details[scaled_key]["value"] = false 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 # Uploads, removes a trailer video # # A preview image for the video is required by ITC and is usually automatically extracted by your browser. # This method will either automatically extract it from the video (using `ffmpeg`) or allow you # to specify it using +preview_image_path+. # If the preview image is specified, `ffmpeg` will not be used. The image resolution will be checked against # expectations (which might be different from the trailer resolution. # # It is recommended to extract the preview image using the spaceship related tools in order to ensure # the appropriate format and resolution are used. # # Note: to extract its resolution and a screenshot preview, the `ffmpeg` tool will be used # # @param trailer_path (String): The path to the trailer. Use nil to remove it # @param sort_order (Fixnum): The sort_order, from 1 to 5 # @param language (String): The language for this screenshot # @param device (String): The device for this screenshot # @param timestamp (String): The optional timestamp of the screenshot to grab # @param preview_image_path (String): The optional image path for the video preview def upload_trailer!(trailer_path, sort_order, language, device, timestamp = "05.00", preview_image_path = nil) raise "No app trailer supported for iphone35" if device == 'iphone35' raise "sort_order must be higher than 0" unless sort_order > 0 raise "sort_order must not be > 3" if sort_order > 3 device_lang_trailers = trailer_data_for_language_and_device(language, device)["value"] existing_sort_orders = device_lang_trailers.map { |s| s["value"]["sortPosition"] } if trailer_path # adding / replacing trailer 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) # Keep a reference of the video_preview here to avoid Ruby getting rid of the Tempfile in the meanwhile video_preview = Utilities.grab_video_preview(trailer_path, timestamp, video_preview_resolution) video_preview_path = video_preview.path end video_preview_file = UploadFile.from_path(video_preview_path) video_preview_data = client.upload_trailer_preview(self, video_preview_file, device) upload_file = UploadFile.from_path(trailer_path) trailer_data = client.upload_trailer(self, upload_file) ts = "00:00:#{timestamp}" ts[8] = ':' new_trailer = { "value" => { "videoAssetToken" => trailer_data["responses"][0]["token"], "descriptionXML" => trailer_data["responses"][0]["descriptionDoc"], "contentType" => upload_file.content_type, "sortPosition" => sort_order, "size" => video_preview_data["length"], "width" => video_preview_data["width"], "height" => video_preview_data["height"], "checksum" => video_preview_data["md5"], "pictureAssetToken" => video_preview_data["token"], "previewFrameTimeCode" => ts.to_s, "isPortrait" => Utilities.portrait?(video_preview_path) } } if existing_sort_orders.include?(sort_order) # replace device_lang_trailers[existing_sort_orders.index(sort_order)] = new_trailer else # add device_lang_trailers << new_trailer end else # removing trailer raise "cannot remove trailer with non existing sort_order" unless existing_sort_orders.include?(sort_order) device_lang_trailers.delete_at(existing_sort_orders.index(sort_order)) end setup_trailers end # Uploads, app review attachments # # while submitting for review, ITC allow developers to attach file. # # Following list can be found at https://appstoreconnect.apple.com # on iOS app edit version, above the attachment label/button there is # a question mark if it is press the a dialog is shown which has the list. # # File types allowed by Apple are: pdf, doc, docx, rtf, pages, xls, xlsx, numbers # zip, rar, plist, crash, jpg, png, mp4 or avi. # # # @param review_attachment_path (String): The path to the attachment file. def upload_review_attachment!(review_attachment_path) raise 'cannot upload review attachment for live edition.' if self.is_live? if !review_attachment_path || review_attachment_path.size < 1 @review_attachment_file.reset! return end raise "cannot find file: #{review_attachment_path}." unless File.exist?(review_attachment_path) review_attachment_file = UploadFile.from_path(review_attachment_path) review_attachment_data = client.upload_app_review_attachment(self, review_attachment_file) raw_data["appReviewInfo"]["attachmentFiles"]["value"] = generate_review_attachment_file(review_attachment_data, review_attachment_path) end # Prefill name, keywords, etc... def unfold_languages { keywords: :keywords, description: :description, supportUrl: :support_url, marketingUrl: :marketing_url, releaseNotes: :release_notes, promotionalText: :promotional_text }.each do |json, attribute| instance_variable_set("@#{attribute}".to_sym, LanguageItem.new(json, languages)) end end def release! client.release!(self.application.apple_id, self.version_id) end def release_to_all_users! client.release_to_all_users!(self.application.apple_id, self.version_id) end ##################################################### # @!group Promo codes ##################################################### def generate_promocodes!(quantity) data = client.generate_app_version_promocodes!( app_id: self.application.apple_id, version_id: self.version_id, quantity: quantity ) Tunes::AppVersionGeneratedPromocodes.factory(data) end # These methods takes care of properly parsing values that # are not returned in the right format, e.g. boolean as string def release_on_approval super == 'true' end def supports_apple_watch !super.nil? end def reject! raise 'Version not rejectable' unless can_reject_version client.reject!(self.application.apple_id, self.version_id) end private 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_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 setup_review_attachment_file review_attachment_file = raw_data["appReviewInfo"]["attachmentFiles"]["value"] @review_attachment_file = nil @review_attachment_file = Tunes::AppReviewAttachment.factory(review_attachment_file) end def supports_review_attachment_file? raw_data["appReviewInfo"]["attachmentFiles"] && raw_data["appReviewInfo"]["attachmentFiles"]["value"] end def supports_app_transit? raw_data["transitAppFile"] != nil end 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 screenshots_data_for_language_and_device(language, device, is_messages) data_field = is_messages ? "messagesScreenshots" : "screenshots" container_data_for_language_and_device(data_field, language, device) end def trailer_data_for_language_and_device(language, device) container_data_for_language_and_device("trailers", language, device) end def container_data_for_language_and_device(data_field, language, device) raise "#{device} isn't a valid device name" unless DisplayFamily.find(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] display_families = lang_details["displayFamilies"]["value"] device_details = display_families.find { |display_family| display_family['name'] == device } raise "Couldn't find device family for #{device}" if device_details.nil? raise "Unexpected state: missing device details for #{device}" unless device_details.key?(data_field) return device_details[data_field] rescue => ex raise "App Store Connect error: #{ex}" end def setup_screenshots # Enable Scaling for all screen sizes that don't have at least one screenshot or at least one trailer (app_preview) # We automatically disable scaling once we upload at least one screenshot language_details = raw_data_details.each do |current_language| language_details = (current_language["displayFamilies"] || {})["value"] (language_details || []).each do |device_language_details| # Do not enable scaling if a screenshot already exists next if device_language_details["screenshots"].nil? next if device_language_details["screenshots"]["value"].count > 0 # Do not enable scaling if a trailer already exists next if device_language_details["trailers"].nil? next if device_language_details["trailers"]["value"].count > 0 # The current row includes screenshots for all device types # so we need to enable scaling for both iOS and watchOS apps device_language_details["scaled"]["value"] = true if device_language_details["scaled"] device_language_details["messagesScaled"]["value"] = true if device_language_details["messagesScaled"] # we unset `scaled` or `messagesScaled` as soon as we upload a # screenshot for this device/language combination end end @screenshots = {} raw_data_details.each do |row| # Now that's one language right here @screenshots[row['language']] = setup_screenshots_for(row) + setup_messages_screenshots_for(row) end end # generates the nested data structure to represent screenshots def setup_screenshots_for(row) return [] if row.nil? || row["displayFamilies"].nil? display_families = row.fetch("displayFamilies", {}).fetch("value", nil) return [] unless display_families result = [] display_families.each do |display_family| # { # "name": "iphone6Plus", # "scaled": { # "value": false, # "isEditable": false, # "isRequired": false, # "errorKeys": null # }, # "screenshots": { # "value": [{ # "value": { # "assetToken": "Purple62/v4/08/0a/04/080a0430-c2cc-2577-f491-9e0a09c58ffe/mzl.pbcpzqyg.jpg", # "sortOrder": 1, # "type": null, # "originalFileName": "ios-414-1.jpg" # }, # "isEditable": true, # "isRequired": false, # "errorKeys": null # }, { # "value": { # "assetToken": "Purple71/v4/de/81/aa/de81aa10-64f6-332e-c974-9ee46adab675/mzl.cshkjvwl.jpg", # "sortOrder": 2, # "type": null, # "originalFileName": "ios-414-2.jpg" # }, # "isEditable": true, # "isRequired": false, # "errorKeys": null # }], # "messagesScaled": { # "value": false, # "isEditable": false, # "isRequired": false, # "errorKeys": null # }, # "messagesScreenshots": { # "value": [{ # "value": { # "assetToken": "Purple62/v4/08/0a/04/080a0430-c2cc-2577-f491-9e0a09c58ffe/mzl.pbcpzqyg.jpg", # "sortOrder": 1, # "type": null, # "originalFileName": "ios-414-1.jpg" # }, # "isEditable": true, # "isRequired": false, # "errorKeys": null # }, { # "value": { # "assetToken": "Purple71/v4/de/81/aa/de81aa10-64f6-332e-c974-9ee46adab675/mzl.cshkjvwl.jpg", # "sortOrder": 2, # "type": null, # "originalFileName": "ios-414-2.jpg" # }, # "isEditable": true, # "isRequired": false, # "errorKeys": null # }], # "isEditable": true, # "isRequired": false, # "errorKeys": null # }, # "trailer": { # "value": null, # "isEditable": true, # "isRequired": false, # "errorKeys": null # } # } display_family.fetch("screenshots", {}).fetch("value", []).each do |screenshot| screenshot_data = screenshot["value"] data = { device_type: display_family['name'], language: row["language"] }.merge(screenshot_data) result << Tunes::AppScreenshot.factory(data) end end return result end # generates the nested data structure to represent screenshots def setup_messages_screenshots_for(row) return [] if row.nil? || row["displayFamilies"].nil? display_families = row.fetch("displayFamilies", {}).fetch("value", nil) return [] unless display_families result = [] display_families.each do |display_family| display_family_screenshots = display_family.fetch("messagesScreenshots", {}) next unless display_family_screenshots display_family_screenshots.fetch("value", []).each do |screenshot| screenshot_data = screenshot["value"] data = { device_type: display_family['name'], language: row["language"], is_imessage: true # to identify imessage screenshots later on (e.g: during download) }.merge(screenshot_data) result << Tunes::AppScreenshot.factory(data) end end return result end 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 # generates the nested data structure to represent trailers def setup_trailers_for(row) return [] if row.nil? || row["displayFamilies"].nil? display_families = row.fetch("displayFamilies", {}).fetch("value", nil) return [] unless display_families result = [] display_families.each do |display_family| display_family.fetch("trailers", {}).fetch("value", []).each do |trailer| trailer_data = trailer["value"] data = { device_type: display_family['name'], language: row["language"] }.merge(trailer_data) result << Tunes::AppTrailer.factory(data) end end return result end # 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 # 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 raw_data_details raw_data["details"]["value"] end end # rubocop:enable Metrics/ClassLength end end