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