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.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/spaceship/base.rb +3 -1
- data/lib/spaceship/client.rb +7 -3
- data/lib/spaceship/du/du_client.rb +100 -0
- data/lib/spaceship/du/upload_file.rb +45 -0
- data/lib/spaceship/du/utilities.rb +75 -0
- data/lib/spaceship/helper/net_http_generic_request.rb +5 -3
- data/lib/spaceship/portal/app.rb +0 -1
- data/lib/spaceship/portal/app_service.rb +3 -3
- data/lib/spaceship/portal/portal_client.rb +1 -2
- data/lib/spaceship/portal/provisioning_profile.rb +1 -2
- data/lib/spaceship/tunes/app_details.rb +6 -0
- data/lib/spaceship/tunes/app_image.rb +53 -0
- data/lib/spaceship/tunes/app_screenshot.rb +6 -17
- data/lib/spaceship/tunes/app_submission.rb +2 -14
- data/lib/spaceship/tunes/app_trailer.rb +70 -0
- data/lib/spaceship/tunes/app_version.rb +293 -30
- data/lib/spaceship/tunes/app_version_ref.rb +19 -0
- data/lib/spaceship/tunes/application.rb +12 -2
- data/lib/spaceship/tunes/build.rb +4 -1
- data/lib/spaceship/tunes/build_train.rb +0 -1
- data/lib/spaceship/tunes/device_type.rb +14 -0
- data/lib/spaceship/tunes/processing_build.rb +0 -1
- data/lib/spaceship/tunes/tester.rb +0 -2
- data/lib/spaceship/tunes/transit_app_file.rb +27 -0
- data/lib/spaceship/tunes/tunes.rb +11 -0
- data/lib/spaceship/tunes/tunes_client.rb +214 -13
- data/lib/spaceship/tunes/user_detail.rb +17 -0
- data/lib/spaceship/version.rb +1 -1
- metadata +25 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bb6191ed6a0a0d23338cc28f0f31d3160a1f2a99
|
4
|
+
data.tar.gz: 536a34d8e0ea5fa2240599de967fb5e221dceee1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3de66c022d9654a8a56360afc24b03824a231bf90aca2e3d06981216def52bc1633de822496a89e30db3103c714a31160633c15d5da8b8bb2119f0181ec3d281
|
7
|
+
data.tar.gz: ba9fe63a3d4410d47a8956d650e08521abc3afd016977c042ff6d55616882f23cb8b36b269fee2cdb26e8d578699e6414a7ae6ffd1e9b02aa2ffa3618b8357e8
|
data/README.md
CHANGED
@@ -149,6 +149,8 @@ I won't go into too much technical details about the various API endpoints, but
|
|
149
149
|
- Managing beta testers
|
150
150
|
- Submitting updates to review
|
151
151
|
- Manaing app metadata
|
152
|
+
- `https://du-itc.itunesconnect.apple.com`:
|
153
|
+
- Upload icons, screenshots, trailers ...
|
152
154
|
|
153
155
|
`spaceship` uses all those API points to offer this seamless experience.
|
154
156
|
|
data/lib/spaceship/base.rb
CHANGED
@@ -27,9 +27,11 @@ module Spaceship
|
|
27
27
|
def get(*keys)
|
28
28
|
lookup(keys)
|
29
29
|
end
|
30
|
-
|
30
|
+
|
31
|
+
alias_method :[], :get
|
31
32
|
|
32
33
|
def set(keys, value)
|
34
|
+
raise "'keys' must be an array, got #{keys.class} instead" unless keys.kind_of?(Array)
|
33
35
|
last = keys.pop
|
34
36
|
ref = lookup(keys) || @hash
|
35
37
|
ref[last] = value
|
data/lib/spaceship/client.rb
CHANGED
@@ -18,6 +18,9 @@ module Spaceship
|
|
18
18
|
attr_reader :client
|
19
19
|
attr_accessor :cookie
|
20
20
|
|
21
|
+
# The user that is currently logged in
|
22
|
+
attr_accessor :user
|
23
|
+
|
21
24
|
# The logger in which all requests are logged
|
22
25
|
# /tmp/spaceship[time].log by default
|
23
26
|
attr_accessor :logger
|
@@ -148,6 +151,7 @@ module Spaceship
|
|
148
151
|
raise NoUserCredentialsError.new, "No login data provided"
|
149
152
|
end
|
150
153
|
|
154
|
+
self.user = user
|
151
155
|
send_login_request(user, password) # different in subclasses
|
152
156
|
end
|
153
157
|
|
@@ -186,10 +190,10 @@ module Spaceship
|
|
186
190
|
|
187
191
|
def request(method, url_or_path = nil, params = nil, headers = {}, &block)
|
188
192
|
if session?
|
189
|
-
headers.merge!({'Cookie' => cookie})
|
193
|
+
headers.merge!({ 'Cookie' => cookie })
|
190
194
|
headers.merge!(csrf_tokens)
|
191
195
|
end
|
192
|
-
headers.merge!({'User-Agent' => 'spaceship'})
|
196
|
+
headers.merge!({ 'User-Agent' => 'spaceship' })
|
193
197
|
|
194
198
|
# Before encoding the parameters, log them
|
195
199
|
log_request(method, url_or_path, params)
|
@@ -246,7 +250,7 @@ module Spaceship
|
|
246
250
|
|
247
251
|
def encode_params(params, headers)
|
248
252
|
params = Faraday::Utils::ParamsHash[params].to_query
|
249
|
-
headers = {'Content-Type' => 'application/x-www-form-urlencoded'}.merge(headers)
|
253
|
+
headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }.merge(headers)
|
250
254
|
return params, headers
|
251
255
|
end
|
252
256
|
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module Spaceship
|
2
|
+
# This class is used to upload Digital files (Images, Videos, JSON files) onto the du-itc service.
|
3
|
+
# Its implementation is tied to the tunes module (in particular using +AppVersion+ instances)
|
4
|
+
class DUClient < Spaceship::Client #:nodoc:
|
5
|
+
#####################################################
|
6
|
+
# @!group Init and Login
|
7
|
+
#####################################################
|
8
|
+
|
9
|
+
def self.hostname
|
10
|
+
"https://du-itc.itunes.apple.com"
|
11
|
+
end
|
12
|
+
|
13
|
+
#####################################################
|
14
|
+
# @!group Images
|
15
|
+
#####################################################
|
16
|
+
|
17
|
+
def upload_screenshot(app_version, upload_file, content_provider_id, sso_token_for_image, device)
|
18
|
+
upload_file(app_version, upload_file, '/upload/image', content_provider_id, sso_token_for_image, screenshot_picture_type(device))
|
19
|
+
end
|
20
|
+
|
21
|
+
def upload_large_icon(app_version, upload_file, content_provider_id, sso_token_for_image)
|
22
|
+
upload_file(app_version, upload_file, '/upload/image', content_provider_id, sso_token_for_image, 'MZPFT.LargeApplicationIcon')
|
23
|
+
end
|
24
|
+
|
25
|
+
def upload_watch_icon(app_version, upload_file, content_provider_id, sso_token_for_image)
|
26
|
+
upload_file(app_version, upload_file, '/upload/image', content_provider_id, sso_token_for_image, 'MZPFT.GizmoAppIcon')
|
27
|
+
end
|
28
|
+
|
29
|
+
def upload_geojson(app_version, upload_file, content_provider_id, sso_token_for_image)
|
30
|
+
upload_file(app_version, upload_file, '/upload/geo-json', content_provider_id, sso_token_for_image)
|
31
|
+
end
|
32
|
+
|
33
|
+
def upload_trailer(app_version, upload_file, content_provider_id, sso_token_for_video)
|
34
|
+
upload_file(app_version, upload_file, '/upload/purple-video', content_provider_id, sso_token_for_video)
|
35
|
+
end
|
36
|
+
|
37
|
+
def upload_video_preview(app_version, upload_file, content_provider_id, sso_token_for_image)
|
38
|
+
upload_file(app_version, upload_file, '/upload/app-screenshot-image', content_provider_id, sso_token_for_image)
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def upload_file(app_version, upload_file, path, content_provider_id, sso_token, du_validation_rule_set = nil)
|
44
|
+
raise "File #{upload_file.file_path} is empty" if upload_file.file_size == 0
|
45
|
+
|
46
|
+
version = app_version.version
|
47
|
+
app_id = app_version.application.apple_id
|
48
|
+
app_type = app_version.app_type
|
49
|
+
|
50
|
+
referrer = app_version.application.url
|
51
|
+
|
52
|
+
r = request(:post) do |req|
|
53
|
+
req.url "#{self.class.hostname}#{path}"
|
54
|
+
req.body = upload_file.bytes
|
55
|
+
req.headers['Accept'] = 'application/json, text/plain, */*'
|
56
|
+
req.headers['Content-Type'] = upload_file.content_type
|
57
|
+
req.headers['X-Apple-Upload-Referrer'] = referrer
|
58
|
+
req.headers['Referrer'] = referrer
|
59
|
+
req.headers['X-Apple-Upload-AppleId'] = app_id
|
60
|
+
req.headers['X-Apple-Jingle-Correlation-Key'] = "#{app_type}:AdamId=#{app_id}:Version=#{version}"
|
61
|
+
req.headers['X-Apple-Upload-itctoken'] = sso_token
|
62
|
+
req.headers['X-Apple-Upload-ContentProviderId'] = content_provider_id
|
63
|
+
req.headers['X-Original-Filename'] = upload_file.file_name
|
64
|
+
req.headers['X-Apple-Upload-Validation-RuleSets'] = du_validation_rule_set if du_validation_rule_set
|
65
|
+
req.headers['Content-Length'] = "#{upload_file.file_size}"
|
66
|
+
req.headers['Connection'] = "keep-alive"
|
67
|
+
end
|
68
|
+
parse_upload_response(r)
|
69
|
+
end
|
70
|
+
|
71
|
+
def picture_type_map
|
72
|
+
# rubocop:enable Style/ExtraSpacing
|
73
|
+
{
|
74
|
+
watch: "MZPFT.SortedN27ScreenShot",
|
75
|
+
ipad: "MZPFT.SortedTabletScreenShot",
|
76
|
+
iphone6: "MZPFT.SortedN61ScreenShot",
|
77
|
+
iphone6Plus: "MZPFT.SortedN56ScreenShot",
|
78
|
+
iphone4: "MZPFT.SortedN41ScreenShot",
|
79
|
+
iphone35: "MZPFT.SortedScreenShot"
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
def screenshot_picture_type(device)
|
84
|
+
device = device.to_sym
|
85
|
+
raise "Unknown picture type for device: #{device}" unless picture_type_map.key? device
|
86
|
+
picture_type_map[device]
|
87
|
+
end
|
88
|
+
|
89
|
+
def parse_upload_response(response)
|
90
|
+
content = response.body
|
91
|
+
if !content['statusCode'].nil? && content['statusCode'] != 200
|
92
|
+
error_codes = ""
|
93
|
+
error_codes = content['errorCodes'].join(',') unless content['errorCodes'].nil?
|
94
|
+
error_message = "[#{error_codes}] #{content['localizedMessage']}"
|
95
|
+
raise UnexpectedResponse.new, error_message
|
96
|
+
end
|
97
|
+
content
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Spaceship
|
2
|
+
# a wrapper around the concept of file required to make uploads to DU
|
3
|
+
class UploadFile
|
4
|
+
attr_reader :file_path
|
5
|
+
attr_reader :file_name
|
6
|
+
attr_reader :file_size
|
7
|
+
attr_reader :content_type
|
8
|
+
attr_reader :bytes
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def from_path(path)
|
12
|
+
raise "Image must exists at path: #{path}" unless File.exist?(path)
|
13
|
+
path = remove_alpha_channel(path) if File.extname(path).downcase == '.png'
|
14
|
+
|
15
|
+
content_type = Utilities.content_type(path)
|
16
|
+
self.new(
|
17
|
+
file_path: path,
|
18
|
+
file_name: File.basename(path),
|
19
|
+
file_size: File.size(path),
|
20
|
+
content_type: content_type,
|
21
|
+
bytes: File.read(path)
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
# As things like screenshots and app icon shouldn't contain the alpha channel
|
26
|
+
# This will copy the image into /tmp to remove the alpha channel there
|
27
|
+
# That's done to not edit the original image
|
28
|
+
def remove_alpha_channel(original)
|
29
|
+
path = "/tmp/#{Digest::MD5.hexdigest(original)}.png"
|
30
|
+
FileUtils.copy(original, path)
|
31
|
+
`sips -s format bmp '#{path}' &> /dev/null ` # &> /dev/null since there is warning because of the extension
|
32
|
+
`sips -s format png '#{path}'`
|
33
|
+
return path
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def initialize(args)
|
40
|
+
args.each do |k, v|
|
41
|
+
instance_variable_set("@#{k}", v) unless v.nil?
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'fastimage'
|
2
|
+
require "English"
|
3
|
+
|
4
|
+
module Spaceship
|
5
|
+
# Set of utility methods useful to work with media files
|
6
|
+
module Utilities #:nodoc:
|
7
|
+
# Identifies the content_type of a file based on its file name extension.
|
8
|
+
# Supports all formats required by DU-UTC right now (video, images and json)
|
9
|
+
# @param path (String) the path to the file
|
10
|
+
def content_type(path)
|
11
|
+
path = path.downcase
|
12
|
+
return 'image/jpeg' if path.end_with?('.jpg')
|
13
|
+
return 'image/png' if path.end_with?('.png')
|
14
|
+
return 'application/json' if path.end_with?('.geojson')
|
15
|
+
return 'video/quicktime' if path.end_with?('.mov')
|
16
|
+
return 'video/mp4' if path.end_with?('.m4v')
|
17
|
+
return 'video/mp4' if path.end_with?('.mp4')
|
18
|
+
raise "Unknown content-type for file #{path}"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Identifies the resolution of a video or an image.
|
22
|
+
# Supports all video and images required by DU-UTC right now
|
23
|
+
# @param path (String) the path to the file
|
24
|
+
def resolution(path)
|
25
|
+
return FastImage.size(path) if content_type(path).start_with?("image")
|
26
|
+
return video_resolution(path) if content_type(path).start_with?("video")
|
27
|
+
raise "Cannot find resolution of file #{path}"
|
28
|
+
end
|
29
|
+
|
30
|
+
# Is the video or image in portrait mode ?
|
31
|
+
# Supports all video and images required by DU-UTC right now
|
32
|
+
# @param path (String) the path to the file
|
33
|
+
def portrait?(path)
|
34
|
+
resolution = resolution(path)
|
35
|
+
resolution[0] < resolution[1]
|
36
|
+
end
|
37
|
+
|
38
|
+
# Grabs a screenshot from the specified video at the specified timestamp using `ffmpeg`
|
39
|
+
# @param video_path (String) the path to the video file
|
40
|
+
# @param timestamp (String) the `ffmpeg` timestamp format (e.g. 00.00)
|
41
|
+
# @param dimensions (Array) the dimension of the screenshot to generate
|
42
|
+
# @return the path to the TempFile containing the generated screenshot
|
43
|
+
def grab_video_preview(video_path, timestamp, dimensions)
|
44
|
+
width, height = dimensions
|
45
|
+
require 'tempfile'
|
46
|
+
tmp = Tempfile.new(['video_preview', ".jpg"])
|
47
|
+
file = tmp.path
|
48
|
+
command = "ffmpeg -y -i \"#{video_path}\" -s #{width}x#{height} -ss \"#{timestamp}\" -vframes 1 \"#{file}\" 2>&1 >/dev/null"
|
49
|
+
# puts "COMMAND: #{command}"
|
50
|
+
`#{command}`
|
51
|
+
raise "Failed to grab screenshot at #{timestamp} from #{video_path} (using #{command})" unless $CHILD_STATUS.to_i == 0
|
52
|
+
tmp.path
|
53
|
+
end
|
54
|
+
|
55
|
+
# identifies the resolution of a video using `ffmpeg`
|
56
|
+
# @param video_path (String) the path to the video file
|
57
|
+
# @return [Array] the resolution of the video
|
58
|
+
def video_resolution(video_path)
|
59
|
+
command = "ffmpeg -i \"#{video_path}\" 2>&1"
|
60
|
+
# puts "COMMAND: #{command}"
|
61
|
+
output = `#{command}`
|
62
|
+
# Note: ffmpeg exits with 1 if no output specified
|
63
|
+
# raise "Failed to find video information from #{video_path} (using #{command})" unless $CHILD_STATUS.to_i == 0
|
64
|
+
output = output.force_encoding("BINARY")
|
65
|
+
video_infos = output.split("\n").select { |l| l =~ /Stream.*Video/ }
|
66
|
+
raise "Unable to find Stream Video information from ffmpeg output of #{command}" if video_infos.count == 0
|
67
|
+
video_info = video_infos[0]
|
68
|
+
res = video_info.match(/.* ([0-9]+)x([0-9]+),.*/)
|
69
|
+
raise "Unable to parse resolution information from #{video_info}" if res.count < 3
|
70
|
+
[res[1].to_i, res[2].to_i]
|
71
|
+
end
|
72
|
+
|
73
|
+
module_function :content_type, :grab_video_preview, :portrait?, :resolution, :video_resolution
|
74
|
+
end
|
75
|
+
end
|
@@ -5,8 +5,10 @@ require 'net/http'
|
|
5
5
|
# Certain apple endpoints return 415 responses if a Content-Type is supplied.
|
6
6
|
# Net::HTTP will default a content-type if none is provided by faraday
|
7
7
|
# This monkey-patch allows us to leave out the content-type if we do not specify one.
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
module Net
|
9
|
+
class HTTPGenericRequest
|
10
|
+
def supply_default_content_type
|
11
|
+
return if content_type
|
12
|
+
end
|
11
13
|
end
|
12
14
|
end
|
data/lib/spaceship/portal/app.rb
CHANGED
@@ -82,9 +82,9 @@ module Spaceship
|
|
82
82
|
|
83
83
|
def ==(other)
|
84
84
|
self.class == other.class &&
|
85
|
-
|
86
|
-
|
87
|
-
|
85
|
+
self.service_id == other.service_id &&
|
86
|
+
self.value == other.value &&
|
87
|
+
self.service_uri == other.service_uri
|
88
88
|
end
|
89
89
|
|
90
90
|
#
|
@@ -1,6 +1,5 @@
|
|
1
1
|
module Spaceship
|
2
2
|
class PortalClient < Spaceship::Client
|
3
|
-
|
4
3
|
#####################################################
|
5
4
|
# @!group Init and Login
|
6
5
|
#####################################################
|
@@ -274,7 +273,7 @@ module Spaceship
|
|
274
273
|
end
|
275
274
|
|
276
275
|
def download_certificate(certificate_id, type)
|
277
|
-
{type: type, certificate_id: certificate_id}.each { |k, v| raise "#{k} must not be nil" if v.nil? }
|
276
|
+
{ type: type, certificate_id: certificate_id }.each { |k, v| raise "#{k} must not be nil" if v.nil? }
|
278
277
|
|
279
278
|
r = request(:post, 'https://developer.apple.com/account/ios/certificate/certificateContentDownload.action', {
|
280
279
|
teamId: team_id,
|
@@ -222,7 +222,7 @@ module Spaceship
|
|
222
222
|
self.type,
|
223
223
|
app.app_id,
|
224
224
|
certificate_parameter,
|
225
|
-
devices.map(&:id)
|
225
|
+
devices.map(&:id))
|
226
226
|
end
|
227
227
|
|
228
228
|
self.new(profile)
|
@@ -256,7 +256,6 @@ module Spaceship
|
|
256
256
|
profile.app.bundle_id == bundle_id
|
257
257
|
end
|
258
258
|
end
|
259
|
-
|
260
259
|
end
|
261
260
|
|
262
261
|
# Represents a Development profile from the Dev Portal
|
@@ -63,6 +63,12 @@ module Spaceship
|
|
63
63
|
# Push all changes that were made back to iTunes Connect
|
64
64
|
def save!
|
65
65
|
client.update_app_details!(application.apple_id, raw_data)
|
66
|
+
rescue Spaceship::TunesClient::ITunesConnectError => ex
|
67
|
+
if ex.to_s == "operation_failed"
|
68
|
+
# That's alright, we get this error message if nothing has changed
|
69
|
+
else
|
70
|
+
raise ex
|
71
|
+
end
|
66
72
|
end
|
67
73
|
|
68
74
|
# Custom Setters
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Spaceship
|
2
|
+
module Tunes
|
3
|
+
# Represents an image hosted on iTunes Connect. Used for icons, screenshots, etc
|
4
|
+
class AppImage < TunesBase
|
5
|
+
HOST_URL = "https://is1-ssl.mzstatic.com/image/thumb"
|
6
|
+
|
7
|
+
attr_accessor :asset_token
|
8
|
+
|
9
|
+
attr_accessor :sort_order
|
10
|
+
|
11
|
+
attr_accessor :original_file_name
|
12
|
+
|
13
|
+
attr_accessor :url
|
14
|
+
|
15
|
+
attr_mapping(
|
16
|
+
'assetToken' => :asset_token,
|
17
|
+
'sortOrder' => :sort_order,
|
18
|
+
'url' => :url,
|
19
|
+
'originalFileName' => :original_file_name
|
20
|
+
)
|
21
|
+
|
22
|
+
class << self
|
23
|
+
def factory(attrs)
|
24
|
+
self.new(attrs)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def reset!(attrs = {})
|
29
|
+
update_raw_data!(
|
30
|
+
{
|
31
|
+
asset_token: nil,
|
32
|
+
original_file_name: nil,
|
33
|
+
sort_order: nil,
|
34
|
+
url: nil
|
35
|
+
}.merge(attrs)
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def setup
|
40
|
+
# Since September 2015 we don't get the url any more, so we have to manually build it
|
41
|
+
self.url = "#{HOST_URL}/#{self.asset_token}/0x0ss.jpg"
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def update_raw_data!(hash)
|
47
|
+
hash.each do |k, v|
|
48
|
+
self.send("#{k}=", v)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -1,27 +1,16 @@
|
|
1
1
|
module Spaceship
|
2
2
|
module Tunes
|
3
3
|
# Represents a screenshot hosted on iTunes Connect
|
4
|
-
class AppScreenshot
|
5
|
-
|
6
|
-
attr_accessor :thumbnail_url
|
7
|
-
|
8
|
-
attr_accessor :sort_order
|
9
|
-
|
10
|
-
attr_accessor :original_file_name
|
11
|
-
|
12
|
-
attr_accessor :url
|
13
|
-
|
4
|
+
class AppScreenshot < Spaceship::Tunes::AppImage
|
14
5
|
attr_accessor :device_type
|
15
6
|
|
16
7
|
attr_accessor :language
|
17
8
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
self.device_type = hash[:device_type]
|
24
|
-
self.language = hash[:language]
|
9
|
+
class << self
|
10
|
+
# Create a new object based on a hash.
|
11
|
+
def factory(attrs)
|
12
|
+
self.new(attrs)
|
13
|
+
end
|
25
14
|
end
|
26
15
|
end
|
27
16
|
end
|