lib/spaceship/tunes/app_version.rb in spaceship-0.7.0 vs lib/spaceship/tunes/app_version.rb in spaceship-0.9.0

- old
+ new

@@ -11,10 +11,13 @@ 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? @@ -39,26 +42,26 @@ attr_accessor :can_beta_test # @return (Bool) Does the binary contain a watch binary? attr_accessor :supports_apple_watch - # @return (String) URL to the full resolution 1024x1024 app icon - attr_accessor :app_icon_url + # @return (Spaceship::Tunes::AppImage) the structure containing information about the large app icon (1024x1024) + attr_accessor :large_app_icon - # @return (String) Name of the original file - attr_accessor :app_icon_original_name + # @return (Spaceship::Tunes::AppImage) the structure containing information about the large watch icon (1024x1024) + attr_accessor :watch_app_icon - # @return (String) URL to the full resolution 1024x1024 app icon - attr_accessor :watch_app_icon_url - - # @return (String) Name of the original file - attr_accessor :watch_app_icon_original_name - # @return (Integer) a unqiue ID for this version generated by iTunes Connect attr_accessor :version_id #### + # GeoJson + #### + # @return (Spaceship::Tunes::TransitAppFile) the structure containing information about the geo json. Can be nil + attr_accessor :transit_app_file + + #### # App Review Information #### # @return (String) App Review Information First Name attr_accessor :review_first_name @@ -82,11 +85,11 @@ #### # Localized values #### - # @return (Array) Raw access the all available languages. You shouldn't use it probbaly + # @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 @@ -103,11 +106,15 @@ 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 + attr_mapping({ + 'appType' => :app_type, 'canBetaTest' => :can_beta_test, 'canPrepareForUpload' => :can_prepare_for_upload, 'canRejectVersion' => :can_reject_version, 'canSendVersionLive' => :can_send_version_live, 'copyright.value' => :copyright, @@ -117,13 +124,14 @@ 'releaseOnApproval.value' => :release_on_approval, 'status' => :raw_status, 'supportsAppleWatch' => :supports_apple_watch, 'versionId' => :version_id, 'version.value' => :version, - 'watchAppIcon.value.originalFileName' => :watch_app_icon_original_name, - 'watchAppIcon.value.url' => :watch_app_icon_url, + # GeoJson + # 'transitAppFile.value' => :transit_app_file + # 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, @@ -193,10 +201,31 @@ # end # languages # 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. + 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 + # Push all changes that were made back to iTunes Connect def save! client.update_app_version!(application.apple_id, is_live?, raw_data) end @@ -210,19 +239,163 @@ # 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 - # Setup the screenshots - @screenshots = {} - raw_data['details']['value'].each do |row| - # Now that's one language right here - @screenshots[row['language']] = setup_screenshots(row) + # 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) + + @large_app_icon.reset!({ asset_token: image_data['token'], original_file_name: 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) + + @watch_app_icon.reset!({ asset_token: image_data["token"], original_file_name: 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 + 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 + + # Uploads, removes a trailer video or change its preview image + # + # 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 ot 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: if the video is already set, the +trailer_path+ is only used to grab the preview screenshot. + # Note: to extract its resolution and a screenshot preview, the `ffmpeg` tool will be used + # + # @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 timestamp (String): The optional timestamp of the screenshot to grab + 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 + # Prefill name, keywords, etc... def unfold_languages { keywords: :keywords, description: :description, @@ -244,31 +417,121 @@ !super.nil? 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_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) + container_data_for_language_and_device("screenshots", language, device) + end + + def trailer_data_for_language_and_device(language, device) + container_data_for_language_and_device("appTrailers", language, device) + end + + 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 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 + # generates the nested data structure to represent screenshots - def setup_screenshots(row) - screenshots = row.fetch('screenshots', {}).fetch('value', nil) + 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 = screenshot['value'] - result << Tunes::AppScreenshot.new({ - url: screenshot['url'], - thumbnail_url: screenshot['thumbNailUrl'], - sort_order: screenshot['sortOrder'], - original_file_name: screenshot['originalFileName'], - device_type: device_type, - language: row['language'] - }) + 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 + @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) + 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 + + # 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 end end