spaceship 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,6 @@ module Spaceship
11
11
  # @return (AppVersion) The version to use for this submission
12
12
  attr_accessor :version
13
13
 
14
- # @return (String) The stage of this submission (start, complete)
15
- attr_accessor :stage
16
-
17
14
  # @return (Boolean) Submitted for Review
18
15
  attr_accessor :submitted_for_review
19
16
 
@@ -116,11 +113,9 @@ module Spaceship
116
113
 
117
114
  # @param application (Spaceship::Tunes::Application) The app this submission is for
118
115
  def create(application, version)
119
- stage = "start"
120
- attrs = client.send_app_submission(application.apple_id, version.raw_data, stage)
116
+ attrs = client.prepare_app_submissions(application.apple_id, application.edit_version.version_id)
121
117
  attrs.merge!(application: application)
122
118
  attrs.merge!(version: version)
123
- attrs.merge!(stage: stage)
124
119
 
125
120
  return self.factory(attrs)
126
121
  end
@@ -128,20 +123,13 @@ module Spaceship
128
123
 
129
124
  # Save and complete the app submission
130
125
  def complete!
131
- @stage = "complete"
132
- client.send_app_submission(application.apple_id, raw_data, @stage)
126
+ client.send_app_submission(application.apple_id, raw_data)
133
127
  @submitted_for_review = true
134
128
  end
135
129
 
136
- # @return (String) An URL to this specific resource. You can enter this URL into your browser
137
- def url
138
- "https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/apps/#{self.application.apple_id}/version/submit/#{self.stage}"
139
- end
140
-
141
130
  def setup
142
131
  @submitted_for_review = false
143
132
  end
144
-
145
133
  end
146
134
  end
147
135
  end
@@ -0,0 +1,70 @@
1
+ module Spaceship
2
+ module Tunes
3
+ # Represents a preview video hosted on iTunes Connect. Used for icons, screenshots, etc
4
+ class AppTrailer < TunesBase
5
+ attr_accessor :video_asset_token
6
+
7
+ attr_accessor :picture_asset_token
8
+
9
+ attr_accessor :descriptionXML
10
+
11
+ attr_accessor :preview_frame_time_code
12
+
13
+ attr_accessor :video_url
14
+
15
+ attr_accessor :preview_image_url
16
+
17
+ attr_accessor :full_sized_preview_image_url
18
+
19
+ attr_accessor :device_type
20
+
21
+ attr_accessor :language
22
+
23
+ attr_mapping(
24
+ 'videoAssetToken' => :video_asset_token,
25
+ 'pictureAssetToken' => :picture_asset_token,
26
+ 'descriptionXML' => :descriptionXML,
27
+ 'previewFrameTimeCode' => :preview_frame_time_code,
28
+ 'isPortrait' => :is_portrait,
29
+ 'videoUrl' => :video_url,
30
+ 'previewImageUrl' => :preview_image_url,
31
+ 'fullSizedPreviewImageUrl' => :full_sized_preview_image_url,
32
+ 'contentType' => :content_type,
33
+ 'videoStatus' => :video_status
34
+ )
35
+
36
+ class << self
37
+ def factory(attrs)
38
+ self.new(attrs)
39
+ end
40
+ end
41
+
42
+ def reset!(attrs = {})
43
+ update_raw_data!
44
+ ({
45
+ video_asset_token: nil,
46
+ picture_asset_token: nil,
47
+ descriptionXML: nil,
48
+ preview_frame_time_code: nil,
49
+ is_portrait: nil,
50
+ video_url: nil,
51
+ preview_image_url: nil,
52
+ full_sized_preview_image_url: nil,
53
+ content_type: nil,
54
+ video_status: nil,
55
+ device_type: nil,
56
+ language: nil
57
+ }.merge(attrs)
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def update_raw_data!(hash)
64
+ hash.each do |k, v|
65
+ self.send("#{k}=", v)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -13,6 +13,9 @@ module Spaceship
13
13
  # @return (String) The copyright information of this app
14
14
  attr_accessor :copyright
15
15
 
16
+ # @return (String) The appType number of this version
17
+ attr_accessor :app_type
18
+
16
19
  # @return (Spaceship::Tunes::AppStatus) What's the current status of this app
17
20
  # e.g. Waiting for Review, Ready for Sale, ...
18
21
  attr_reader :app_status
@@ -41,21 +44,21 @@ module Spaceship
41
44
  # @return (Bool) Does the binary contain a watch binary?
42
45
  attr_accessor :supports_apple_watch
43
46
 
44
- # @return (String) URL to the full resolution 1024x1024 app icon
45
- attr_accessor :app_icon_url
46
-
47
- # @return (String) Name of the original file
48
- attr_accessor :app_icon_original_name
49
-
50
- # @return (String) URL to the full resolution 1024x1024 app icon
51
- attr_accessor :watch_app_icon_url
47
+ # @return (Spaceship::Tunes::AppImage) the structure containing information about the large app icon (1024x1024)
48
+ attr_accessor :large_app_icon
52
49
 
53
- # @return (String) Name of the original file
54
- attr_accessor :watch_app_icon_original_name
50
+ # @return (Spaceship::Tunes::AppImage) the structure containing information about the large watch icon (1024x1024)
51
+ attr_accessor :watch_app_icon
55
52
 
56
53
  # @return (Integer) a unqiue ID for this version generated by iTunes Connect
57
54
  attr_accessor :version_id
58
55
 
56
+ ####
57
+ # GeoJson
58
+ ####
59
+ # @return (Spaceship::Tunes::TransitAppFile) the structure containing information about the geo json. Can be nil
60
+ attr_accessor :transit_app_file
61
+
59
62
  ####
60
63
  # App Review Information
61
64
  ####
@@ -84,7 +87,7 @@ module Spaceship
84
87
  # Localized values
85
88
  ####
86
89
 
87
- # @return (Array) Raw access the all available languages. You shouldn't use it probbaly
90
+ # @return (Array) Raw access the all available languages. You shouldn't use it probably
88
91
  attr_accessor :languages
89
92
 
90
93
  # @return (Hash) A hash representing the keywords in all languages
@@ -105,7 +108,11 @@ module Spaceship
105
108
  # @return (Hash) Represents the screenshots of this app version (read-only)
106
109
  attr_reader :screenshots
107
110
 
111
+ # @return (Hash) Represents the trailers of this app version (read-only)
112
+ attr_reader :trailers
113
+
108
114
  attr_mapping({
115
+ 'appType' => :app_type,
109
116
  'canBetaTest' => :can_beta_test,
110
117
  'canPrepareForUpload' => :can_prepare_for_upload,
111
118
  'canRejectVersion' => :can_reject_version,
@@ -119,8 +126,9 @@ module Spaceship
119
126
  'supportsAppleWatch' => :supports_apple_watch,
120
127
  'versionId' => :version_id,
121
128
  'version.value' => :version,
122
- 'watchAppIcon.value.originalFileName' => :watch_app_icon_original_name,
123
- 'watchAppIcon.value.url' => :watch_app_icon_url,
129
+
130
+ # GeoJson
131
+ # 'transitAppFile.value' => :transit_app_file
124
132
 
125
133
  # App Review Information
126
134
  'appReviewInfo.firstName.value' => :review_first_name,
@@ -195,6 +203,27 @@ module Spaceship
195
203
  # languages
196
204
  # end
197
205
 
206
+ # Returns an array of all builds that can be sent to review
207
+ def candidate_builds
208
+ res = client.candidate_builds(self.application.apple_id, self.version_id)
209
+ builds = []
210
+ res.each do |attrs|
211
+ next unless attrs["type"] == "BUILD" # I don't know if it can be something else.
212
+ builds << Tunes::Build.factory(attrs)
213
+ end
214
+ return builds
215
+ end
216
+
217
+ # Select a build to be submitted for Review.
218
+ # You have to pass a build you got from - candidate_builds
219
+ # Don't forget to call save! after calling this method
220
+ def select_build(build)
221
+ raw_data.set(['preReleaseBuildVersionString', 'value'], build.build_version)
222
+ raw_data.set(['preReleaseBuildTrainVersionString'], build.train_version)
223
+ raw_data.set(['preReleaseBuildUploadDate'], build.upload_date)
224
+ true
225
+ end
226
+
198
227
  # Push all changes that were made back to iTunes Connect
199
228
  def save!
200
229
  client.update_app_version!(application.apple_id, is_live?, raw_data)
@@ -212,13 +241,157 @@ module Spaceship
212
241
  # Properly parse the AppStatus
213
242
  status = raw_data['status']
214
243
  @app_status = Tunes::AppStatus.get_from_string(status)
244
+ setup_large_app_icon
245
+ setup_watch_app_icon
246
+ setup_transit_app_file
247
+ setup_screenshots
248
+ setup_trailers
249
+ end
215
250
 
216
- # Setup the screenshots
217
- @screenshots = {}
218
- raw_data['details']['value'].each do |row|
219
- # Now that's one language right here
220
- @screenshots[row['language']] = setup_screenshots(row)
251
+ # Uploads or removes the large icon
252
+ # @param icon_path (String): The path to the icon. Use nil to remove it
253
+ def upload_large_icon!(icon_path)
254
+ unless icon_path
255
+ @large_app_icon.reset!
256
+ return
257
+ end
258
+ upload_image = UploadFile.from_path icon_path
259
+ image_data = client.upload_large_icon(self, upload_image)
260
+
261
+ @large_app_icon.reset!({ asset_token: image_data['token'], original_file_name: upload_image.file_name })
262
+ end
263
+
264
+ # Uploads or removes the watch icon
265
+ # @param icon_path (String): The path to the icon. Use nil to remove it
266
+ def upload_watch_icon!(icon_path)
267
+ unless icon_path
268
+ @watch_app_icon.reset!
269
+ return
270
+ end
271
+ upload_image = UploadFile.from_path icon_path
272
+ image_data = client.upload_watch_icon(self, upload_image)
273
+
274
+ @watch_app_icon.reset!({ asset_token: image_data["token"], original_file_name: upload_image.file_name })
275
+ end
276
+
277
+ # Uploads or removes the transit app file
278
+ # @param icon_path (String): The path to the geojson file. Use nil to remove it
279
+ def upload_geojson!(geojson_path)
280
+ unless geojson_path
281
+ raw_data["transitAppFile"]["value"] = nil
282
+ @transit_app_file = nil
283
+ return
284
+ end
285
+ upload_file = UploadFile.from_path geojson_path
286
+ geojson_data = client.upload_geojson(self, upload_file)
287
+
288
+ @transit_app_file = Tunes::TransitAppFile.factory({}) if @transit_app_file.nil?
289
+ @transit_app_file .url = nil # response.headers['Location']
290
+ @transit_app_file.asset_token = geojson_data["token"]
291
+ @transit_app_file.name = upload_file.file_name
292
+ @transit_app_file.time_stamp = Time.now.to_i * 1000 # works without but...
293
+ end
294
+
295
+ # Uploads or removes a screenshot
296
+ # @param icon_path (String): The path to the screenshot. Use nil to remove it
297
+ # @param sort_order (Fixnum): The sort_order, from 1 to 5
298
+ # @param language (String): The language for this screenshot
299
+ # @param device (string): The device for this screenshot
300
+ def upload_screenshot!(screenshot_path, sort_order, language, device)
301
+ raise "sort_order must be positive" unless sort_order > 0
302
+ raise "sort_order must not be > 5" if sort_order > 5
303
+ # this will also check both language and device parameters
304
+ device_lang_screenshots = screenshots_data_for_language_and_device(language, device)["value"]
305
+ existing_sort_orders = device_lang_screenshots.map { |s| s["value"]["sortOrder"] }
306
+ if screenshot_path # adding / replacing
307
+ upload_file = UploadFile.from_path screenshot_path
308
+ screenshot_data = client.upload_screenshot(self, upload_file, device)
309
+
310
+ new_screenshot = {
311
+ "value" => {
312
+ "assetToken" => screenshot_data["token"],
313
+ "sortOrder" => sort_order,
314
+ "url" => nil,
315
+ "thumbNailUrl" => nil,
316
+ "originalFileName" => upload_file.file_name
317
+ }
318
+ }
319
+ if existing_sort_orders.include?(sort_order) # replace
320
+ device_lang_screenshots[existing_sort_orders.index(sort_order)] = new_screenshot
321
+ else # add
322
+ device_lang_screenshots << new_screenshot
323
+ end
324
+ else # removing
325
+ raise "cannot remove screenshot with non existing sort_order" unless existing_sort_orders.include?(sort_order)
326
+ device_lang_screenshots.delete_at(existing_sort_orders.index(sort_order))
221
327
  end
328
+ setup_screenshots
329
+ end
330
+
331
+ # Uploads, removes a trailer video or change its preview image
332
+ #
333
+ # A preview image for the video is required by ITC and is usually automatically extracted by your browser.
334
+ # This method will either automatically extract it from the video (using `ffmpeg) or allow you
335
+ # to specify it using +preview_image_path+.
336
+ # If the preview image is specified, ffmpeg` will ot be used. The image resolution will be checked against
337
+ # expectations (which might be different from the trailer resolution.
338
+ #
339
+ # It is recommended to extract the preview image using the spaceship related tools in order to ensure
340
+ # the appropriate format and resolution are used.
341
+ #
342
+ # Note: if the video is already set, the +trailer_path+ is only used to grab the preview screenshot.
343
+ # Note: to extract its resolution and a screenshot preview, the `ffmpeg` tool will be used
344
+ #
345
+ # @param icon_path (String): The path to the screenshot. Use nil to remove it
346
+ # @param sort_order (Fixnum): The sort_order, from 1 to 5
347
+ # @param language (String): The language for this screenshot
348
+ # @param device (String): The device for this screenshot
349
+ # @param timestamp (String): The optional timestamp of the screenshot to grab
350
+ def upload_trailer!(trailer_path, language, device, timestamp = "05.00", preview_image_path = nil)
351
+ raise "No app trailer supported for iphone35" if device == 'iphone35'
352
+
353
+ device_lang_trailer = trailer_data_for_language_and_device(language, device)
354
+ if trailer_path # adding / replacing trailer / replacing preview
355
+ raise "Invalid timestamp #{timestamp}" if (timestamp =~ /^[0-9][0-9].[0-9][0-9]$/).nil?
356
+
357
+ if preview_image_path
358
+ check_preview_screenshot_resolution(preview_image_path, device)
359
+ video_preview_path = preview_image_path
360
+ else
361
+ # IDEA: optimization, we could avoid fetching the screenshot if the timestamp hasn't changed
362
+ video_preview_resolution = video_preview_resolution_for(device, trailer_path)
363
+ video_preview_path = Utilities.grab_video_preview(trailer_path, timestamp, video_preview_resolution)
364
+ end
365
+ video_preview_file = UploadFile.from_path video_preview_path
366
+ video_preview_data = client.upload_trailer_preview(self, video_preview_file)
367
+
368
+ trailer = device_lang_trailer["value"]
369
+ if trailer.nil? # add trailer
370
+ upload_file = UploadFile.from_path trailer_path
371
+ trailer_data = client.upload_trailer(self, upload_file)
372
+ trailer_data = trailer_data['responses'][0]
373
+ trailer = {
374
+ "videoAssetToken" => trailer_data["token"],
375
+ "descriptionXML" => trailer_data["descriptionDoc"],
376
+ "contentType" => upload_file.content_type
377
+ }
378
+ device_lang_trailer["value"] = trailer
379
+ end
380
+ # add / update preview
381
+ # different format required
382
+ ts = "00:00:#{timestamp}"
383
+ ts[8] = ':'
384
+
385
+ trailer.merge!({
386
+ "pictureAssetToken" => video_preview_data["token"],
387
+ "previewFrameTimeCode" => "#{ts}",
388
+ "isPortrait" => Utilities.portrait?(video_preview_path)
389
+ })
390
+ else # removing trailer
391
+ raise "cannot remove non existing trailer" if device_lang_trailer["value"].nil?
392
+ device_lang_trailer["value"] = nil
393
+ end
394
+ setup_trailers
222
395
  end
223
396
 
224
397
  # Prefill name, keywords, etc...
@@ -246,29 +419,119 @@ module Spaceship
246
419
 
247
420
  private
248
421
 
422
+ def setup_large_app_icon
423
+ large_app_icon = raw_data["largeAppIcon"]["value"]
424
+ @large_app_icon = nil
425
+ @large_app_icon = Tunes::AppImage.factory(large_app_icon) if large_app_icon
426
+ end
427
+
428
+ def setup_watch_app_icon
429
+ watch_app_icon = raw_data["watchAppIcon"]["value"]
430
+ @watch_app_icon = nil
431
+ @watch_app_icon = Tunes::AppImage.factory(watch_app_icon) if watch_app_icon
432
+ end
433
+
434
+ def setup_transit_app_file
435
+ transit_app_file = raw_data["transitAppFile"]["value"]
436
+ @transit_app_file = nil
437
+ @transit_app_file = Tunes::TransitAppFile.factory(transit_app_file) if transit_app_file
438
+ end
439
+
440
+ def screenshots_data_for_language_and_device(language, device)
441
+ container_data_for_language_and_device("screenshots", language, device)
442
+ end
443
+
444
+ def trailer_data_for_language_and_device(language, device)
445
+ container_data_for_language_and_device("appTrailers", language, device)
446
+ end
447
+
448
+ def container_data_for_language_and_device(data_field, language, device)
449
+ raise "#{device} isn't a valid device name" unless DeviceType.exists?(device)
450
+
451
+ languages = raw_data_details.select { |d| d["language"] == language }
452
+ # IDEA: better error for non existing language
453
+ raise "#{language} isn't an activated language" unless languages.count > 0
454
+ lang_details = languages[0]
455
+ devices_details = lang_details[data_field]["value"]
456
+ raise "Unexpected state: missing device details for #{device}" unless devices_details.key? device
457
+ devices_details[device]
458
+ end
459
+
460
+ def setup_screenshots
461
+ @screenshots = {}
462
+ raw_data_details.each do |row|
463
+ # Now that's one language right here
464
+ @screenshots[row['language']] = setup_screenshots_for(row)
465
+ end
466
+ end
467
+
249
468
  # generates the nested data structure to represent screenshots
250
- def setup_screenshots(row)
251
- screenshots = row.fetch('screenshots', {}).fetch('value', nil)
469
+ def setup_screenshots_for(row)
470
+ screenshots = row.fetch("screenshots", {}).fetch("value", nil)
252
471
  return [] unless screenshots
253
472
 
254
473
  result = []
255
474
 
256
475
  screenshots.each do |device_type, value|
257
- value['value'].each do |screenshot|
258
- screenshot = screenshot['value']
259
- result << Tunes::AppScreenshot.new({
260
- url: screenshot['url'],
261
- thumbnail_url: screenshot['thumbNailUrl'],
262
- sort_order: screenshot['sortOrder'],
263
- original_file_name: screenshot['originalFileName'],
264
- device_type: device_type,
265
- language: row['language']
266
- })
476
+ value["value"].each do |screenshot|
477
+ screenshot_data = screenshot["value"]
478
+ data = {
479
+ device_type: device_type,
480
+ language: row["language"]
481
+ }.merge(screenshot_data)
482
+ result << Tunes::AppScreenshot.factory(data)
267
483
  end
268
484
  end
269
485
 
270
486
  return result
271
487
  end
488
+
489
+ def setup_trailers
490
+ @trailers = {}
491
+ raw_data_details.each do |row|
492
+ # Now that's one language right here
493
+ @trailers[row["language"]] = setup_trailers_for(row)
494
+ end
495
+ end
496
+
497
+ # generates the nested data structure to represent trailers
498
+ def setup_trailers_for(row)
499
+ trailers = row.fetch("appTrailers", {}).fetch("value", nil)
500
+ return [] unless trailers
501
+
502
+ result = []
503
+
504
+ trailers.each do |device_type, value|
505
+ trailer_data = value["value"]
506
+ next if trailer_data.nil?
507
+ data = {
508
+ device_type: device_type,
509
+ language: row["language"]
510
+ }.merge(trailer_data)
511
+ result << Tunes::AppTrailer.factory(data)
512
+ end
513
+
514
+ return result
515
+ end
516
+
517
+ # identify the required resolution for this particular video screenshot
518
+ def video_preview_resolution_for(device, video_path)
519
+ is_portrait = Utilities.portrait?(video_path)
520
+ TunesClient.video_preview_resolution_for(device, is_portrait)
521
+ end
522
+
523
+ # ensure the specified preview screenshot has the expected resolution the specified target +device+
524
+ def check_preview_screenshot_resolution(preview_screenshot_path, device)
525
+ is_portrait = Utilities.portrait?(preview_screenshot_path)
526
+ expected_resolution = TunesClient.video_preview_resolution_for(device, is_portrait)
527
+ actual_resolution = Utilities.resolution(preview_screenshot_path)
528
+ orientation = is_portrait ? "portrait" : "landscape"
529
+ raise "Invalid #{orientation} screenshot resolution for device #{device}. Should be #{expected_resolution}" unless (actual_resolution == expected_resolution)
530
+ end
531
+
532
+ def raw_data_details
533
+ raw_data["details"]["value"]
534
+ end
272
535
  end
273
536
  end
274
537
  end