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