push_kit-apns 1.0.0.pre.beta1
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 +7 -0
- data/.gitignore +46 -0
- data/.rspec +2 -0
- data/.rubocop.yml +21 -0
- data/Gemfile +5 -0
- data/Guardfile +17 -0
- data/README.md +118 -0
- data/Rakefile +8 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/lib/push_kit/apns.rb +65 -0
- data/lib/push_kit/apns/constants.rb +9 -0
- data/lib/push_kit/apns/http_client.rb +277 -0
- data/lib/push_kit/apns/notification.rb +257 -0
- data/lib/push_kit/apns/notification/localization.rb +61 -0
- data/lib/push_kit/apns/push_client.rb +207 -0
- data/lib/push_kit/apns/token_generator.rb +97 -0
- data/push_kit.gemspec +33 -0
- data/spec/push_kit/apns/constants_spec.rb +9 -0
- data/spec/push_kit/apns/notification/localization_spec.rb +89 -0
- data/spec/push_kit/apns/notification_spec.rb +264 -0
- data/spec/spec_helper.rb +85 -0
- data/spec/support/have_accessor.rb +19 -0
- metadata +189 -0
@@ -0,0 +1,257 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PushKit
|
4
|
+
module APNS
|
5
|
+
# The Notification class is used to build a payload that can be delivered by APNS.
|
6
|
+
#
|
7
|
+
class Notification
|
8
|
+
# The acceptable notification priorities.
|
9
|
+
#
|
10
|
+
# :eco - Send the push message at a time that takes into account power considerations for the device.
|
11
|
+
# Notifications with this priority might be grouped and delivered in bursts.
|
12
|
+
# They are throttled, and in some cases are not delivered.
|
13
|
+
#
|
14
|
+
# :immediate - Send the push message immediately. Notifications with this priority must trigger an alert, sound,
|
15
|
+
# or badge on the target device.
|
16
|
+
# It is an error to use this priority for a push notification that contains only the
|
17
|
+
# content-available key.
|
18
|
+
#
|
19
|
+
# @return [Hash]
|
20
|
+
#
|
21
|
+
PRIORITIES = { eco: 5, immediate: 10 }.freeze
|
22
|
+
|
23
|
+
# The title of the notification.
|
24
|
+
#
|
25
|
+
# @return [String|PushKit::APNS::Notification::Localization]
|
26
|
+
#
|
27
|
+
attr_accessor :title
|
28
|
+
|
29
|
+
# The subtitle of the notification.
|
30
|
+
#
|
31
|
+
# @return [String|PushKit::APNS::Notification::Localization]
|
32
|
+
#
|
33
|
+
attr_accessor :subtitle
|
34
|
+
|
35
|
+
# The body of the notification.
|
36
|
+
#
|
37
|
+
# @return [String|PushKit::APNS::Notification::Localization]
|
38
|
+
#
|
39
|
+
attr_accessor :body
|
40
|
+
|
41
|
+
# The badge number to assign to the app's icon on the home screen.
|
42
|
+
#
|
43
|
+
# @return [Integer]
|
44
|
+
#
|
45
|
+
attr_accessor :badge
|
46
|
+
|
47
|
+
# The name of a sound file included in your app's bundle to play when the notification is received.
|
48
|
+
#
|
49
|
+
# Alternatively, you can specify :default to play the device's default notification sound chosen by the user.
|
50
|
+
#
|
51
|
+
# @return [String]
|
52
|
+
#
|
53
|
+
attr_accessor :sound
|
54
|
+
|
55
|
+
# The localization key for the title of the action button in the notification.
|
56
|
+
#
|
57
|
+
# When provided, the system displays an alert that includes both the 'Close' and 'View' buttons.
|
58
|
+
# The value is used as a key to get a localized string in the current localization to use for the right button's
|
59
|
+
# title (which is the action button) instead of the default 'View' text.
|
60
|
+
#
|
61
|
+
# @return [String]
|
62
|
+
#
|
63
|
+
attr_accessor :action_key
|
64
|
+
|
65
|
+
# The notification's category matching one of your app's registered categories.
|
66
|
+
#
|
67
|
+
# @return [String]
|
68
|
+
#
|
69
|
+
attr_accessor :category
|
70
|
+
|
71
|
+
# The filename of an image in your app's bundle, with or without the filename extension.
|
72
|
+
#
|
73
|
+
# The image is used as the launch image when users tap the action button or move the action slider.
|
74
|
+
# If this property is not specified, the system either uses the previous snapshot, uses the image identified by
|
75
|
+
# the UILaunchImageFile key in your app's Info.plist file, or falls back to 'Default.png'.
|
76
|
+
#
|
77
|
+
# @return [String]
|
78
|
+
#
|
79
|
+
attr_accessor :launch_image
|
80
|
+
|
81
|
+
# An array of custom attributes to add to the root of the payload.
|
82
|
+
#
|
83
|
+
# Bear in mind that the size of a payload is limited to these sizes:
|
84
|
+
# For regular remote notifications, the maximum size of the payload is 4KB (4096 bytes).
|
85
|
+
# For Voice over Internet Protocol (VoIP) notifications, the maximum size is 5KB (5120 bytes).
|
86
|
+
#
|
87
|
+
# @return [Hash]
|
88
|
+
#
|
89
|
+
attr_accessor :metadata
|
90
|
+
|
91
|
+
# Indicate that the notification should trigger a background update.
|
92
|
+
#
|
93
|
+
# When enabled, the system wakes up your app in the background and delivers the notification to its app delegate.
|
94
|
+
# The notification is delivered without presenting any visual or auditory notification to the user.
|
95
|
+
#
|
96
|
+
# @return [Boolean]
|
97
|
+
#
|
98
|
+
attr_accessor :content_available
|
99
|
+
|
100
|
+
# Indicate that the notification has mutable content.
|
101
|
+
#
|
102
|
+
# When enabled, the system will use an extension in your app to allow you to make modifications to the
|
103
|
+
# notification before it is delivered to the user.
|
104
|
+
#
|
105
|
+
# @return [Boolean]
|
106
|
+
#
|
107
|
+
attr_accessor :mutable_content
|
108
|
+
|
109
|
+
# A canonical UUID that identifies the notification.
|
110
|
+
#
|
111
|
+
# You can generate a UUID using `SecureRandom.uuid`.
|
112
|
+
# If there is an error sending the notification, APNS uses this value to identify the notification to your server.
|
113
|
+
# If you omit this attribute, a new UUID is created by APNS when sending the notification.
|
114
|
+
#
|
115
|
+
# @return [String]
|
116
|
+
#
|
117
|
+
attr_accessor :uuid
|
118
|
+
|
119
|
+
# The collapse identifier for the notification.
|
120
|
+
#
|
121
|
+
# Multiple notifications with the same collapse identifier are displayed to the user as a single notification.
|
122
|
+
# The value of this attribute must not exceed 64 bytes.
|
123
|
+
#
|
124
|
+
# @return [String]
|
125
|
+
#
|
126
|
+
attr_accessor :collapse_uuid
|
127
|
+
|
128
|
+
# The priority of the notification.
|
129
|
+
#
|
130
|
+
# This can either be an Integer representing a specific priority, or one of the symbols from the PRIORITIES
|
131
|
+
# constant.
|
132
|
+
#
|
133
|
+
# @return [Integer|Symbol]
|
134
|
+
#
|
135
|
+
attr_accessor :priority
|
136
|
+
|
137
|
+
# The time when the notification is no longer valid and can be discarded.
|
138
|
+
#
|
139
|
+
# If this value is nonzero, APNS stores the notification and tries to deliver it at least once,
|
140
|
+
# repeating the attempt as needed if it is unable to deliver the notification the first time.
|
141
|
+
# If the value is 0, APNS treats the notification as if it expires immediately and does not store the
|
142
|
+
# notification or attempt to redeliver it.
|
143
|
+
#
|
144
|
+
# @return [Time]
|
145
|
+
#
|
146
|
+
attr_accessor :expiration
|
147
|
+
|
148
|
+
# The token representing a device capable of receiving notifications.
|
149
|
+
#
|
150
|
+
# @return [String]
|
151
|
+
#
|
152
|
+
attr_accessor :device_token
|
153
|
+
|
154
|
+
# Creates a new notification.
|
155
|
+
#
|
156
|
+
def initialize
|
157
|
+
@content_available = false
|
158
|
+
@mutable_content = false
|
159
|
+
end
|
160
|
+
|
161
|
+
# @return [Integer] The actual priority value required by APNS.
|
162
|
+
#
|
163
|
+
def apns_priority
|
164
|
+
return priority unless priority.is_a?(Symbol)
|
165
|
+
|
166
|
+
PRIORITIES[priority]
|
167
|
+
end
|
168
|
+
|
169
|
+
# @return [Integer] The actual expiration time value required by APNS.
|
170
|
+
#
|
171
|
+
def apns_expiration
|
172
|
+
return expiration unless expiration.is_a?(Time)
|
173
|
+
|
174
|
+
expiration.utc.to_i
|
175
|
+
end
|
176
|
+
|
177
|
+
# Duplicate this notification for each of the provided tokens, setting the token on the notification.
|
178
|
+
#
|
179
|
+
# @param tokens [Splat] A collection of device tokens to duplicate the notification for.
|
180
|
+
# @return [Array] A collection notifications, one for each of the device tokens.
|
181
|
+
#
|
182
|
+
def for_tokens(*tokens)
|
183
|
+
tokens.map do |token|
|
184
|
+
notification = dup
|
185
|
+
notification.device_token = token
|
186
|
+
notification
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# @return [Hash] The headers to include in the HTTP/2 request.
|
191
|
+
#
|
192
|
+
def headers
|
193
|
+
headers = {}
|
194
|
+
headers['apns-id'] = uuid if uuid.is_a?(String)
|
195
|
+
headers['apns-collapse-id'] = collapse_uuid if collapse_uuid.is_a?(String)
|
196
|
+
headers['apns-priority'] = apns_priority if apns_priority.is_a?(Integer)
|
197
|
+
headers['apns-expiration'] = apns_expiration if apns_expiration.is_a?(Integer)
|
198
|
+
headers
|
199
|
+
end
|
200
|
+
|
201
|
+
# @return [Hash] The payload to use as the body of the HTTP/2 request.
|
202
|
+
#
|
203
|
+
def payload
|
204
|
+
payload = metadata.is_a?(Hash) ? metadata.dup : {}
|
205
|
+
|
206
|
+
if (aps = payload_aps) && aps.any?
|
207
|
+
payload['aps'] = aps
|
208
|
+
end
|
209
|
+
|
210
|
+
payload
|
211
|
+
end
|
212
|
+
|
213
|
+
private
|
214
|
+
|
215
|
+
# @return [Hash] The contents of the key path ':aps' within the payload.
|
216
|
+
#
|
217
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
218
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
219
|
+
def payload_aps
|
220
|
+
aps = {}
|
221
|
+
|
222
|
+
if (alert = payload_alert) && alert.any?
|
223
|
+
aps['alert'] = alert
|
224
|
+
end
|
225
|
+
|
226
|
+
aps['badge'] = badge if badge.is_a?(Integer)
|
227
|
+
aps['sound'] = sound.to_s if sound.is_a?(String) || sound.is_a?(Symbol)
|
228
|
+
aps['category'] = category if category.is_a?(String)
|
229
|
+
aps['content-available'] = '1' if content_available
|
230
|
+
aps['mutable-content'] = '1' if mutable_content
|
231
|
+
|
232
|
+
aps
|
233
|
+
end
|
234
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
235
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
236
|
+
|
237
|
+
# @return [Hash] The contents of the key path ':aps -> :alert' within the payload.
|
238
|
+
#
|
239
|
+
def payload_alert
|
240
|
+
alert = {}
|
241
|
+
|
242
|
+
{ 'title' => title, 'subtitle' => subtitle, 'body' => body }.each do |key, value|
|
243
|
+
if value.is_a?(String)
|
244
|
+
alert[key] = value
|
245
|
+
elsif value.is_a?(Localization)
|
246
|
+
alert.merge!(value.payload(key.to_sym))
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
alert['action-loc-key'] = action_key if action_key.is_a?(String)
|
251
|
+
alert['launch-image'] = launch_image if launch_image.is_a?(String)
|
252
|
+
|
253
|
+
alert
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PushKit
|
4
|
+
module APNS
|
5
|
+
class Notification
|
6
|
+
# The Localization class provides a way to localize specific notification attributes.
|
7
|
+
#
|
8
|
+
# You can localize the :title, :subtitle and :body attributes of a notification.
|
9
|
+
#
|
10
|
+
class Localization
|
11
|
+
# @return [String] The localization key as defined in your app's localization file.
|
12
|
+
#
|
13
|
+
attr_accessor :key
|
14
|
+
|
15
|
+
# @return [Array] The arguments used to format the localization string.
|
16
|
+
#
|
17
|
+
attr_accessor :arguments
|
18
|
+
|
19
|
+
# Creates a Localization instance which wraps the given localization key and it's formatting arguments.
|
20
|
+
#
|
21
|
+
# @param key [String] The key as defined in your app's localization file.
|
22
|
+
# @param arguments [Array] The arguments to format the localization string with.
|
23
|
+
#
|
24
|
+
def initialize(key: nil, arguments: nil)
|
25
|
+
@key = key
|
26
|
+
@arguments = arguments
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns a payload which can be merged into the :alert Hash within the notification's payload.
|
30
|
+
#
|
31
|
+
# @param attribute [Symbol] The attribute to generate the payload for.
|
32
|
+
# @return [Hash] The partial payload to merge into the notification's payload.
|
33
|
+
#
|
34
|
+
def payload(attribute)
|
35
|
+
prefix = prefix(attribute)
|
36
|
+
|
37
|
+
return nil unless prefix.is_a?(String)
|
38
|
+
|
39
|
+
components = { "#{prefix}loc-key" => @key }
|
40
|
+
components["#{prefix}loc-args"] = arguments if arguments.is_a?(Array) && arguments.any?
|
41
|
+
components
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# Returns the prefix for keys in the payload.
|
47
|
+
#
|
48
|
+
# @param attribute [Symbol] The attribute to determine the prefix for.
|
49
|
+
# @return [String] The prefix for the keys in the payload.
|
50
|
+
#
|
51
|
+
def prefix(attribute)
|
52
|
+
case attribute
|
53
|
+
when :title then 'title-'
|
54
|
+
when :subtitle then 'subtitle-'
|
55
|
+
when :body then ''
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,207 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PushKit
|
4
|
+
module APNS
|
5
|
+
# The PushClient class provides an interface for delivering push notifications using HTTP/2.
|
6
|
+
#
|
7
|
+
class PushClient
|
8
|
+
# @return [Hash] The default hosts for each of the environments supported by APNS.
|
9
|
+
#
|
10
|
+
HOSTS = {
|
11
|
+
production: 'api.push.apple.com',
|
12
|
+
development: 'api.development.push.apple.com'
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
# @return [Hash] The default port numbers supported by APNS.
|
16
|
+
#
|
17
|
+
PORTS = {
|
18
|
+
default: 443,
|
19
|
+
alternative: 2197
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
# @return [String] The host to use when connecting to the server.
|
23
|
+
#
|
24
|
+
attr_reader :host
|
25
|
+
|
26
|
+
# @return [Integer] The port to use when connecting to the server.
|
27
|
+
#
|
28
|
+
attr_reader :port
|
29
|
+
|
30
|
+
# @return [String] The APNS topic, usually the app's bundle identifier.
|
31
|
+
#
|
32
|
+
attr_reader :topic
|
33
|
+
|
34
|
+
# @return [PushKit::APNS::TokenGenerator] The token generator to authenticate requests with.
|
35
|
+
#
|
36
|
+
attr_reader :token_generator
|
37
|
+
|
38
|
+
# Creates a new PushClient for the specified environment and port.
|
39
|
+
#
|
40
|
+
# You can manually specify the host like 'api.push.apple.com' or use the convenience symbols :production and
|
41
|
+
# :development which correspond to the host for that environment.
|
42
|
+
#
|
43
|
+
# You can also manually manually specify a port number like 443 or use the convenience symbols :default and
|
44
|
+
# :alternative which correspond to the port numbers in Apple's documentation.
|
45
|
+
#
|
46
|
+
# @param options [Hash] The options for the client:
|
47
|
+
# host [String|Symbol] The host (can also be :production or :development).
|
48
|
+
# port [Integer|Symbol] The port number (can also be :default or :alternative).
|
49
|
+
# topic [String] The APNS topic (matches the app's bundle identifier).
|
50
|
+
# token_generator [PushKit::APNS::TokenGenerator] The token generator to authenticate the requests with.
|
51
|
+
#
|
52
|
+
def initialize(options = {})
|
53
|
+
extract_host(options)
|
54
|
+
extract_port(options)
|
55
|
+
extract_topic(options)
|
56
|
+
extract_token_generator(options)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Deliver one or more notifications.
|
60
|
+
#
|
61
|
+
# @param notifications [Splat] The notifications to deliver.
|
62
|
+
#
|
63
|
+
def deliver(*notifications, &block)
|
64
|
+
unless notifications.all?(Notification)
|
65
|
+
raise ArgumentError, 'The notifications must all be instances of PushKit::APNS::Notification.'
|
66
|
+
end
|
67
|
+
|
68
|
+
latch = Concurrent::CountDownLatch.new(notifications.count)
|
69
|
+
|
70
|
+
notifications.each do |notification|
|
71
|
+
deliver_single(notification) do |*args|
|
72
|
+
latch.count_down
|
73
|
+
block.call(*args) if block.is_a?(Proc)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
latch.wait
|
78
|
+
|
79
|
+
nil
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
# @return [HTTPClient] The HTTP client.
|
85
|
+
#
|
86
|
+
def client
|
87
|
+
@client ||= HTTPClient.new("https://#{host}:#{port}")
|
88
|
+
end
|
89
|
+
|
90
|
+
# Deliver a single notification.
|
91
|
+
#
|
92
|
+
# @param notification [PushKit::APNS::Notification] The notification to deliver.
|
93
|
+
# @return [Boolean] Whether the notification was sent.
|
94
|
+
#
|
95
|
+
def deliver_single(notification, &block)
|
96
|
+
token = notification.device_token
|
97
|
+
|
98
|
+
unless token.is_a?(String) && token.length.positive?
|
99
|
+
raise ArgumentError, 'The notification must have a device token.'
|
100
|
+
end
|
101
|
+
|
102
|
+
headers = headers(notification)
|
103
|
+
payload = notification.payload.to_json
|
104
|
+
|
105
|
+
request = { method: :post, path: "/3/device/#{token}", headers: headers, body: payload }
|
106
|
+
|
107
|
+
client.request(**request) do |code, response_headers, response_body|
|
108
|
+
handle_result(notification, code, response_headers, response_body, &block)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Handle the result of a single delivery.
|
113
|
+
#
|
114
|
+
# @param notification [PushKit::APNS::Notification] The notification to handle delivery of.
|
115
|
+
# @param code [Integer] The response status code.
|
116
|
+
# @param headers [Hash] The response headers.
|
117
|
+
# @param body [String] The response body.
|
118
|
+
# @param block [Proc] A block to call after processing the response.
|
119
|
+
#
|
120
|
+
def handle_result(notification, code, headers, body, &block)
|
121
|
+
uuid = headers['apns-id']
|
122
|
+
notification.uuid = uuid if uuid.is_a?(String) && uuid.length.positive?
|
123
|
+
|
124
|
+
success = code.between?(200, 299)
|
125
|
+
|
126
|
+
begin
|
127
|
+
result = JSON.parse(body)
|
128
|
+
rescue JSON::JSONError
|
129
|
+
result = nil
|
130
|
+
end
|
131
|
+
|
132
|
+
return unless block.is_a?(Proc)
|
133
|
+
|
134
|
+
block.call(notification, success, result)
|
135
|
+
end
|
136
|
+
|
137
|
+
# Returns the additional request headers for a notification.
|
138
|
+
#
|
139
|
+
# @param notification [PushKit::APNS::Notification] The notification to compute additional headers for.
|
140
|
+
# @return [Hash] The additional headers for the notification.
|
141
|
+
#
|
142
|
+
def headers(notification)
|
143
|
+
headers = {
|
144
|
+
'content-type' => 'application/json',
|
145
|
+
'apns-topic' => topic
|
146
|
+
}
|
147
|
+
|
148
|
+
headers.merge!(token_generator.headers)
|
149
|
+
headers.merge!(notification.headers)
|
150
|
+
|
151
|
+
headers.each_with_object({}) do |(key, value), hash|
|
152
|
+
hash[key] = value.to_s unless value.nil?
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Extract the :host attribute from the options and store it in an instance variable.
|
157
|
+
#
|
158
|
+
# @param options [Hash] The options passed in to the `initialize` method.
|
159
|
+
#
|
160
|
+
def extract_host(options)
|
161
|
+
@host = options[:host]
|
162
|
+
@host = HOSTS[@host] if @host.is_a?(Symbol)
|
163
|
+
|
164
|
+
return if @host.is_a?(String) && @host.length.positive?
|
165
|
+
|
166
|
+
raise ArgumentError, 'The :host attribute must be provided.'
|
167
|
+
end
|
168
|
+
|
169
|
+
# Extract the :port attribute from the options and store it in an instance variable.
|
170
|
+
#
|
171
|
+
# @param options [Hash] The options passed in to the `initialize` method.
|
172
|
+
#
|
173
|
+
def extract_port(options)
|
174
|
+
@port = options[:port]
|
175
|
+
@port = PORTS[@port] if @port.is_a?(Symbol)
|
176
|
+
|
177
|
+
return if @port.is_a?(Integer) && @port.between?(1, 655_35)
|
178
|
+
|
179
|
+
raise ArgumentError, 'The :port must be a number between 1 and 65535.'
|
180
|
+
end
|
181
|
+
|
182
|
+
# Extract the :topic attribute from the options and store it in an instance variable.
|
183
|
+
#
|
184
|
+
# @param options [Hash] The options passed in to the `initialize` method.
|
185
|
+
#
|
186
|
+
def extract_topic(options)
|
187
|
+
@topic = options[:topic]
|
188
|
+
|
189
|
+
return if @topic.is_a?(String) && @topic.length.positive?
|
190
|
+
|
191
|
+
raise ArgumentError, 'The :topic must be provided.'
|
192
|
+
end
|
193
|
+
|
194
|
+
# Extract the :token_generator attribute from the options and store it in an instance variable.
|
195
|
+
#
|
196
|
+
# @param options [Hash] The options passed in to the `initialize` method.
|
197
|
+
#
|
198
|
+
def extract_token_generator(options)
|
199
|
+
@token_generator = options[:token_generator]
|
200
|
+
|
201
|
+
return if @token_generator.is_a?(TokenGenerator)
|
202
|
+
|
203
|
+
raise ArgumentError, 'The :token_generator attribute must be a `PushKit::APNS::TokenGenerator` instance.'
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|