spaceship 0.7.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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