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.
@@ -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