gravel 0.1.0.alpha1
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 +45 -0
- data/.rspec +3 -0
- data/Gemfile +3 -0
- data/README.md +116 -0
- data/Rakefile +6 -0
- data/bin/console +7 -0
- data/bin/setup +6 -0
- data/gravel.gemspec +29 -0
- data/lib/gravel.rb +16 -0
- data/lib/gravel/apns.rb +224 -0
- data/lib/gravel/apns/auto_token.rb +107 -0
- data/lib/gravel/apns/notification.rb +203 -0
- data/lib/gravel/apns/notification/localization.rb +88 -0
- data/lib/gravel/constants.rb +3 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/unit/gravel/constants_spec.rb +5 -0
- metadata +147 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e0af416f5d3e8c7a7048a6b56a57ab951b3d701f
|
4
|
+
data.tar.gz: 19462716bc99702d0fdc51365ae7df20caac0657
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b61e235460ef57ed8651ba8848609f0b4604adfe23d31067b25393a8ad2b5be8fd1ca099241152995932950178e485d0c07bf3eb25235b9619bac522e592c2b4
|
7
|
+
data.tar.gz: fae035c45f431282047db1a5286c8e9e68de1884faaddd130559051704615ccfadcfb86993d975863c957fa17b5a8ddc97d8c0592aabdd153dadc1a8b1f66e5f
|
data/.gitignore
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# Packages #
|
2
|
+
############
|
3
|
+
*.7z
|
4
|
+
*.dmg
|
5
|
+
*.gz
|
6
|
+
*.iso
|
7
|
+
*.jar
|
8
|
+
*.rar
|
9
|
+
*.tar
|
10
|
+
*.zip
|
11
|
+
|
12
|
+
# Logs #
|
13
|
+
########
|
14
|
+
*.log
|
15
|
+
|
16
|
+
# Databases #
|
17
|
+
#############
|
18
|
+
*.sql
|
19
|
+
*.sqlite
|
20
|
+
|
21
|
+
# OS Files #
|
22
|
+
############
|
23
|
+
.DS_Store
|
24
|
+
.Trashes
|
25
|
+
ehthumbs.db
|
26
|
+
Icon?
|
27
|
+
Thumbs.db
|
28
|
+
|
29
|
+
# Vagrant #
|
30
|
+
###########
|
31
|
+
.vagrant
|
32
|
+
|
33
|
+
# Ruby Files #
|
34
|
+
##############
|
35
|
+
/.bundle/
|
36
|
+
/.yardoc
|
37
|
+
/Gemfile.lock
|
38
|
+
/_yardoc/
|
39
|
+
/coverage/
|
40
|
+
/doc/
|
41
|
+
/pkg/
|
42
|
+
/spec/reports/
|
43
|
+
/tmp/
|
44
|
+
/vendor/bundle/
|
45
|
+
*.gem
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
# Gravel
|
2
|
+
|
3
|
+
Unified Push Notifications
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
You can install **Gravel** using the following command:
|
8
|
+
|
9
|
+
$ gem install gravel
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
Currently, Gravel only supports APNS (but FCM support is on the list).
|
14
|
+
|
15
|
+
### APNS
|
16
|
+
|
17
|
+
Gravel uses the new APNS token based authentication so if you don't have an
|
18
|
+
APNS token, you'll need to generate one using the Apple Developer portal.
|
19
|
+
|
20
|
+
You'll also need the ID for that key, your team's ID and the bundle identifier
|
21
|
+
of your application (which should be passed as the 'topic' value - see below).
|
22
|
+
|
23
|
+
The APNS class provides the connection to APNS, you can create an instance
|
24
|
+
like this:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
apns = Gravel::APNS.new(
|
28
|
+
key: Gravel::APNS.key_from_file('/path/to/APNsAuthKey_XXXXXXXXXX.p8'),
|
29
|
+
key_id: 'XXXXXXXXXX',
|
30
|
+
team_id: 'XXXXXXXXXX',
|
31
|
+
topic: 'com.example.app'
|
32
|
+
)
|
33
|
+
```
|
34
|
+
|
35
|
+
There are also a few other parameters you can specify, you can check the
|
36
|
+
documentation for the full list but a couple that you might need are:
|
37
|
+
|
38
|
+
##### :environment
|
39
|
+
|
40
|
+
This can be set to either ```:production``` or ```:development```.
|
41
|
+
|
42
|
+
The default value is ```:development```.
|
43
|
+
|
44
|
+
##### :concurrency
|
45
|
+
|
46
|
+
This tells Gravel how many connections it should open to APNS.
|
47
|
+
|
48
|
+
One notification can be sent at a time through a connection, so opening multiple connections will improve the speed at which you can crunch through a notification task.
|
49
|
+
|
50
|
+
The default value is ```1```.
|
51
|
+
|
52
|
+
---
|
53
|
+
|
54
|
+
Next, you'll want to send a notification. You can create a notification like this:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
notification = Gravel::APNS::Notification.new
|
58
|
+
notification.title = 'Hello, World!'
|
59
|
+
notification.body = 'How are you today?'
|
60
|
+
notification.sound = :default
|
61
|
+
notification.device_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
62
|
+
```
|
63
|
+
|
64
|
+
Finally, you can send the notification:
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
apns.send(notification) do |success, response|
|
68
|
+
puts "Success: #{success}, Response: #{response}"
|
69
|
+
end
|
70
|
+
```
|
71
|
+
|
72
|
+
The requests are all asynchronous, so if you need to wait for them to finish
|
73
|
+
you can call the ```wait``` method:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
apns.wait
|
77
|
+
```
|
78
|
+
|
79
|
+
This will block the thread until all requests have completed.
|
80
|
+
|
81
|
+
---
|
82
|
+
|
83
|
+
If you're sending notifications to lots of devices, there's a helper method
|
84
|
+
that allows you to quickly generate the same notification for each device token:
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
tokens = [
|
88
|
+
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
89
|
+
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
90
|
+
]
|
91
|
+
|
92
|
+
notifications = notification.for_device_tokens(*tokens)
|
93
|
+
```
|
94
|
+
|
95
|
+
Then you can just loop over each notification and deliver it:
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
notifications.each do |notification|
|
99
|
+
apns.send(notification) do |success, result|
|
100
|
+
puts "Success: #{success}, Response: #{response}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
```
|
104
|
+
|
105
|
+
---
|
106
|
+
|
107
|
+
You should keep the APNS instance in memory for as long a possible, this
|
108
|
+
keeps the connection(s) to APNS open.
|
109
|
+
|
110
|
+
See [Communicating with APNs - Best Practices for Managing Connections](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW8) for more info on this.
|
111
|
+
|
112
|
+
## Development
|
113
|
+
|
114
|
+
After checking out the repo, run `rake spec` to run the tests.
|
115
|
+
|
116
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/gravel.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'gravel/constants'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'gravel'
|
8
|
+
spec.version = Gravel::VERSION
|
9
|
+
spec.authors = ['Nialto Services']
|
10
|
+
spec.email = ['support@nialtoservices.co.uk']
|
11
|
+
|
12
|
+
spec.summary = %q{Unified Push Notifications}
|
13
|
+
spec.homepage = 'https://github.com/nialtoservices/gravel'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ['lib']
|
19
|
+
|
20
|
+
spec.metadata['yard.run'] = 'yri'
|
21
|
+
|
22
|
+
spec.add_dependency 'jwt', '~> 1.5'
|
23
|
+
spec.add_dependency 'net-http2', '~> 0.14.1'
|
24
|
+
|
25
|
+
spec.add_development_dependency 'bundler', '~> 1.11'
|
26
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
27
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
28
|
+
spec.add_development_dependency 'yard', '~> 0.9.8'
|
29
|
+
end
|
data/lib/gravel.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'jwt'
|
3
|
+
require 'net-http2'
|
4
|
+
require 'openssl'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'uri'
|
7
|
+
|
8
|
+
require 'gravel/apns'
|
9
|
+
require 'gravel/apns/auto_token'
|
10
|
+
require 'gravel/apns/notification'
|
11
|
+
require 'gravel/apns/notification/localization'
|
12
|
+
|
13
|
+
# Gravel Unified Push Notifications.
|
14
|
+
#
|
15
|
+
module Gravel
|
16
|
+
end
|
data/lib/gravel/apns.rb
ADDED
@@ -0,0 +1,224 @@
|
|
1
|
+
module Gravel
|
2
|
+
# Manages the connection to APNS.
|
3
|
+
# You should keep this instance somewhere instead of recreating it every
|
4
|
+
# time you need to send notifications.
|
5
|
+
#
|
6
|
+
class APNS
|
7
|
+
# The production APNS server URL.
|
8
|
+
#
|
9
|
+
PRODUCTION_URL = 'api.push.apple.com'
|
10
|
+
|
11
|
+
# The development APNS server URL.
|
12
|
+
#
|
13
|
+
DEVELOPMENT_URL = 'api.development.push.apple.com'
|
14
|
+
|
15
|
+
# The default APNS port.
|
16
|
+
#
|
17
|
+
DEFAULT_PORT = 443
|
18
|
+
|
19
|
+
# The alternative APNS port.
|
20
|
+
#
|
21
|
+
ALTERNATIVE_PORT = 2197
|
22
|
+
|
23
|
+
class << self
|
24
|
+
# Attempt to load a key from the specified path.
|
25
|
+
#
|
26
|
+
# @param path [String] The path to the key file.
|
27
|
+
# @return [OpenSSL::PKey::EC] The key.
|
28
|
+
#
|
29
|
+
def key_from_file(path)
|
30
|
+
unless File.file?(path)
|
31
|
+
raise "A key could not be loaded from: #{path}"
|
32
|
+
end
|
33
|
+
|
34
|
+
OpenSSL::PKey::EC.new(File.read(path))
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# The topic to pass to APNS.
|
39
|
+
# This is usually the bundle identifier of the application you're sending
|
40
|
+
# push notifications to.
|
41
|
+
#
|
42
|
+
# @return [String] The APNS topic.
|
43
|
+
#
|
44
|
+
attr_reader :topic
|
45
|
+
|
46
|
+
# Create a new APNS instance.
|
47
|
+
#
|
48
|
+
# @param alternative_port [Boolean] (optional) Should we use the default (443) or the alternative (2197) port?
|
49
|
+
# @param concurrency [Integer] (optional) How many connections to APNS should we open?
|
50
|
+
# @param environment [Symbol] (optional) :production or :development
|
51
|
+
# @param key [OpenSSL::PKey::EC] An elliptic curve key (APNS authentication).
|
52
|
+
# @param key_id [String] The key's identifier.
|
53
|
+
# @param team_id [String] The team's identifier.
|
54
|
+
# @param topic [String] The topic to pass to APNS.
|
55
|
+
# @return [Gravel::APNS] An APNS instance.
|
56
|
+
#
|
57
|
+
def initialize(options = {})
|
58
|
+
options = {
|
59
|
+
alternative_port: false,
|
60
|
+
concurrency: 1,
|
61
|
+
environment: :development
|
62
|
+
}.merge(options)
|
63
|
+
|
64
|
+
unless options[:topic].is_a?(String)
|
65
|
+
raise 'The APNS topic is required.'
|
66
|
+
end
|
67
|
+
|
68
|
+
unless [true, false].include?(options[:alternative_port])
|
69
|
+
raise 'The alternative port should be a boolean value.'
|
70
|
+
end
|
71
|
+
|
72
|
+
unless options[:concurrency].is_a?(Integer) && options[:concurrency] > 0
|
73
|
+
raise 'Concurrency should be specified as an Integer greater than zero.'
|
74
|
+
end
|
75
|
+
|
76
|
+
unless [:development, :production].include?(options[:environment])
|
77
|
+
raise 'The environment should be either :production or :development.'
|
78
|
+
end
|
79
|
+
|
80
|
+
host = case options[:environment]
|
81
|
+
when :development
|
82
|
+
DEVELOPMENT_URL
|
83
|
+
when :production
|
84
|
+
PRODUCTION_URL
|
85
|
+
end
|
86
|
+
|
87
|
+
port = options[:alternative_port] ? ALTERNATIVE_PORT : DEFAULT_PORT
|
88
|
+
|
89
|
+
@auto_token = Gravel::APNS::AutoToken.new(options[:team_id], options[:key_id], options[:key])
|
90
|
+
@queue = Queue.new
|
91
|
+
@topic = options[:topic]
|
92
|
+
@url = "https://#{host}:#{port}"
|
93
|
+
|
94
|
+
@workers = options[:concurrency].times.map do
|
95
|
+
client = NetHttp2::Client.new(@url)
|
96
|
+
|
97
|
+
thread = Thread.new(client) do |client|
|
98
|
+
Thread.current.abort_on_exception = true
|
99
|
+
|
100
|
+
loop do
|
101
|
+
Thread.current[:processing] = false
|
102
|
+
|
103
|
+
notification, block = @queue.pop
|
104
|
+
|
105
|
+
Thread.current[:processing] = true
|
106
|
+
|
107
|
+
process_notification(notification, client, &block)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
{ client: client, thread: thread }
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# The identifier for the developer's team.
|
116
|
+
#
|
117
|
+
# @return [String] The team's identifier.
|
118
|
+
#
|
119
|
+
def team_id
|
120
|
+
@auto_token.team_id
|
121
|
+
end
|
122
|
+
|
123
|
+
# The identifier for the APNS key.
|
124
|
+
#
|
125
|
+
# @return [String] The key's identifier.
|
126
|
+
#
|
127
|
+
def key_id
|
128
|
+
@auto_token.key_id
|
129
|
+
end
|
130
|
+
|
131
|
+
# Push a notification onto the send queue.
|
132
|
+
#
|
133
|
+
# @param notification [Gravel::APNS::Notification] The notification to send.
|
134
|
+
# @param block [Proc] The block to call when the request has completed.
|
135
|
+
# @return [Boolean] Whether or not the notification was sent.
|
136
|
+
#
|
137
|
+
def send(notification, &block)
|
138
|
+
if @workers.nil? || @workers.empty?
|
139
|
+
raise "There aren't any workers to process this notification!"
|
140
|
+
end
|
141
|
+
|
142
|
+
@queue.push([notification, block])
|
143
|
+
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
|
147
|
+
# Wait for all threads to finish processing.
|
148
|
+
#
|
149
|
+
def wait
|
150
|
+
threads = @workers.map { |w| w[:thread] }
|
151
|
+
|
152
|
+
until @queue.empty? && threads.all? { |t| t[:processing] == false }
|
153
|
+
# Waiting for the threads to finish ...
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Close all clients and terminate all workers.
|
158
|
+
#
|
159
|
+
def close
|
160
|
+
@queue.clear
|
161
|
+
|
162
|
+
if @workers.is_a?(Array)
|
163
|
+
@workers.each do |payload|
|
164
|
+
payload[:thread].kill
|
165
|
+
payload[:client].close
|
166
|
+
end
|
167
|
+
|
168
|
+
@workers = nil
|
169
|
+
end
|
170
|
+
|
171
|
+
nil
|
172
|
+
end
|
173
|
+
|
174
|
+
private
|
175
|
+
# Process a notification (in a worker).
|
176
|
+
#
|
177
|
+
# @param notification [Gravel::APNS::Notification] The notification to process.
|
178
|
+
# @param client [NetHttp2::Client] The client to use.
|
179
|
+
# @param block [Proc] The block to call on completion.
|
180
|
+
#
|
181
|
+
def process_notification(notification, client, &block)
|
182
|
+
unless notification.is_a?(Gravel::APNS::Notification)
|
183
|
+
raise 'The notification must be an instance of Gravel::APNS::Notification.'
|
184
|
+
end
|
185
|
+
|
186
|
+
unless client.is_a?(NetHttp2::Client)
|
187
|
+
raise 'The client must be an instance of NetHttp2::Client.'
|
188
|
+
end
|
189
|
+
|
190
|
+
unless notification.device_token
|
191
|
+
block.call(false) if block_given?
|
192
|
+
return
|
193
|
+
end
|
194
|
+
|
195
|
+
path = "/3/device/#{notification.device_token}"
|
196
|
+
|
197
|
+
headers = Hash.new
|
198
|
+
headers['authorization'] = @auto_token.bearer_token
|
199
|
+
headers['apns-topic'] = @topic
|
200
|
+
|
201
|
+
if notification.uuid
|
202
|
+
headers['apns-id'] = notification.uuid
|
203
|
+
end
|
204
|
+
|
205
|
+
if notification.collapse_id
|
206
|
+
headers['apns-collapse-id'] = notification.collapse_id
|
207
|
+
end
|
208
|
+
|
209
|
+
if notification.priority
|
210
|
+
headers['apns-priority'] = notification.priority.to_s
|
211
|
+
end
|
212
|
+
|
213
|
+
if notification.expiration && notification.expiration.is_a?(Time)
|
214
|
+
headers['apns-expiration'] = notification.expiration.utc.to_i.to_s
|
215
|
+
end
|
216
|
+
|
217
|
+
body = notification.payload.to_json
|
218
|
+
|
219
|
+
response = client.call(:post, path, headers: headers, body: body)
|
220
|
+
|
221
|
+
block.call(response.ok?, response) if block_given?
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module Gravel
|
2
|
+
class APNS
|
3
|
+
# Used internally to generate JWT tokens for APNS.
|
4
|
+
#
|
5
|
+
class AutoToken
|
6
|
+
# The identifier for the developer's team.
|
7
|
+
#
|
8
|
+
# @return [String] The team identifier.
|
9
|
+
#
|
10
|
+
attr_reader :team_id
|
11
|
+
|
12
|
+
# The identifier for the APNS key.
|
13
|
+
#
|
14
|
+
# @return [String] The key identifier.
|
15
|
+
#
|
16
|
+
attr_reader :key_id
|
17
|
+
|
18
|
+
# Create a new AutoToken instance.
|
19
|
+
#
|
20
|
+
# @param team_id [String] The team identifier.
|
21
|
+
# @param key_id [String] The key identifier.
|
22
|
+
# @param key [OpenSSL::PKey::EC] The private key.
|
23
|
+
# @return [Gravel::APNS::AutoToken] An AutoToken instance.
|
24
|
+
#
|
25
|
+
def initialize(team_id, key_id, key)
|
26
|
+
unless team_id.is_a?(String)
|
27
|
+
raise 'The team identifier must be a string.'
|
28
|
+
end
|
29
|
+
|
30
|
+
unless key_id.is_a?(String)
|
31
|
+
raise 'The key identifier must be a string.'
|
32
|
+
end
|
33
|
+
|
34
|
+
unless key_id.length == 10
|
35
|
+
raise 'The key identifier does not appear to be valid.'
|
36
|
+
end
|
37
|
+
|
38
|
+
unless key.is_a?(OpenSSL::PKey::EC)
|
39
|
+
raise 'The key must be an elliptic curve key.'
|
40
|
+
end
|
41
|
+
|
42
|
+
unless key.private?
|
43
|
+
raise 'The key must contain a private key.'
|
44
|
+
end
|
45
|
+
|
46
|
+
@key = key
|
47
|
+
@key_id = key_id
|
48
|
+
@team_id = team_id
|
49
|
+
@token_generation_mutex = Mutex.new
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get the next token to use.
|
53
|
+
#
|
54
|
+
# @return [String] The next token.
|
55
|
+
#
|
56
|
+
def token
|
57
|
+
if require_token_generation?
|
58
|
+
@token_generation_mutex.synchronize do
|
59
|
+
# Double check if we need to regenerate the token.
|
60
|
+
# This could happen if two threads try to concurrently access
|
61
|
+
# the token after it has expired (or before initial generation).
|
62
|
+
if require_token_generation?
|
63
|
+
@last_generated = time
|
64
|
+
@token = generate_token
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
@token
|
70
|
+
end
|
71
|
+
|
72
|
+
# Generate a bearer token.
|
73
|
+
#
|
74
|
+
# @return [String] A bearer token.
|
75
|
+
#
|
76
|
+
def bearer_token
|
77
|
+
'Bearer ' + token
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
# Check if we need to generate a new token.
|
82
|
+
#
|
83
|
+
def require_token_generation?
|
84
|
+
@token == nil || @last_generated == nil || @last_generated < (time - 3540)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Generate a new token.
|
88
|
+
#
|
89
|
+
# @return [String] The token.
|
90
|
+
#
|
91
|
+
def generate_token
|
92
|
+
headers = { 'kid' => @key_id }
|
93
|
+
claims = { 'iat' => Time.now.utc.to_i, 'iss' => @team_id }
|
94
|
+
|
95
|
+
JWT.encode(claims, @key, 'ES256', headers)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Get the current time in seconds (Epoch).
|
99
|
+
#
|
100
|
+
# @return [Integer] The current time in seconds.
|
101
|
+
#
|
102
|
+
def time
|
103
|
+
Time.now.utc.to_i
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,203 @@
|
|
1
|
+
module Gravel
|
2
|
+
class APNS
|
3
|
+
# A notification for an Apple device (using APNS).
|
4
|
+
#
|
5
|
+
class Notification
|
6
|
+
PRIORITY_IMMEDIATE = 10
|
7
|
+
PRIORITY_ECO = 5
|
8
|
+
|
9
|
+
# The title of the notification.
|
10
|
+
# You can provide a localization on this value.
|
11
|
+
#
|
12
|
+
# @return [String|Gravel::APNS::Notification::Localization] The title.
|
13
|
+
#
|
14
|
+
attr_accessor :title
|
15
|
+
|
16
|
+
# The subtitle of the notification.
|
17
|
+
# You can provide a localization on this value.
|
18
|
+
#
|
19
|
+
# @return [String|Gravel::APNS::Notification::Localization] The subtitle.
|
20
|
+
#
|
21
|
+
attr_accessor :subtitle
|
22
|
+
|
23
|
+
# The body of the notification.
|
24
|
+
# You can provide a localization on this value.
|
25
|
+
#
|
26
|
+
# @return [String|Gravel::APNS::Notification::Localization] The body.
|
27
|
+
#
|
28
|
+
attr_accessor :body
|
29
|
+
|
30
|
+
# The name of the sound file to play when notifying the user.
|
31
|
+
#
|
32
|
+
# @return [String] The sound file name.
|
33
|
+
#
|
34
|
+
attr_accessor :sound
|
35
|
+
|
36
|
+
# The badge number to show on the application icon.
|
37
|
+
#
|
38
|
+
# @return [Integer] The badge number.
|
39
|
+
#
|
40
|
+
attr_accessor :badge
|
41
|
+
|
42
|
+
# A localization key to use when populating the content of the 'View' button.
|
43
|
+
#
|
44
|
+
# @param [String] The action button's localization key.
|
45
|
+
#
|
46
|
+
attr_accessor :action_key
|
47
|
+
|
48
|
+
# A category to identify the notification's type.
|
49
|
+
# This should match one of the identifier values as defined in your
|
50
|
+
# application.
|
51
|
+
#
|
52
|
+
# @param [String] The notification's category.
|
53
|
+
#
|
54
|
+
attr_accessor :category
|
55
|
+
|
56
|
+
# The filename of an image to use when launching the app.
|
57
|
+
#
|
58
|
+
# @param [String] The launch image filename.
|
59
|
+
#
|
60
|
+
attr_accessor :launch_image
|
61
|
+
|
62
|
+
# Set to true to trigger a silent notification in your application.
|
63
|
+
# This is useful to trigger a background app refresh.
|
64
|
+
#
|
65
|
+
# @return [Boolean] Whether or not new content is available.
|
66
|
+
#
|
67
|
+
attr_accessor :content_available
|
68
|
+
|
69
|
+
# The mutable content of the notification.
|
70
|
+
#
|
71
|
+
# @return [Hash] The mutable content.
|
72
|
+
#
|
73
|
+
attr_accessor :mutable_content
|
74
|
+
|
75
|
+
# A unique identifier for this notification.
|
76
|
+
#
|
77
|
+
# @return [String] The unique identifier.
|
78
|
+
#
|
79
|
+
attr_accessor :uuid
|
80
|
+
|
81
|
+
# A group identifier for the notification.
|
82
|
+
# This allows APNS to identify similar messages and collapse them
|
83
|
+
# into a single notification.
|
84
|
+
#
|
85
|
+
# @return [String] The collapse identifier.
|
86
|
+
#
|
87
|
+
attr_accessor :collapse_id
|
88
|
+
|
89
|
+
# The priority of the notification.
|
90
|
+
#
|
91
|
+
# @return [Integer] The priority.
|
92
|
+
#
|
93
|
+
attr_accessor :priority
|
94
|
+
|
95
|
+
# A time when the notification is no longer valid and APNS should stop
|
96
|
+
# attempting to deliver the notification.
|
97
|
+
#
|
98
|
+
# @return [Time] The expiration time.
|
99
|
+
#
|
100
|
+
attr_accessor :expiration
|
101
|
+
|
102
|
+
# A token representing the device you want to send the notification to.
|
103
|
+
#
|
104
|
+
# @return [String] The device token.
|
105
|
+
#
|
106
|
+
attr_accessor :device_token
|
107
|
+
|
108
|
+
# Create a new APNS notification.
|
109
|
+
#
|
110
|
+
# @return [Gravel::APNS::Notification] The notification object.
|
111
|
+
#
|
112
|
+
def initialize
|
113
|
+
self.content_available = false
|
114
|
+
self.uuid = SecureRandom.uuid
|
115
|
+
end
|
116
|
+
|
117
|
+
# Quickly create the same notification for multiple device tokens.
|
118
|
+
#
|
119
|
+
# @param tokens [Splat] An array of device tokens.
|
120
|
+
# @return [Array] An array of notifications.
|
121
|
+
#
|
122
|
+
def for_device_tokens(*tokens)
|
123
|
+
tokens.map do |token|
|
124
|
+
notification = self.dup
|
125
|
+
notification.uuid = SecureRandom.uuid
|
126
|
+
notification.device_token = token
|
127
|
+
notification
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Generate the APNS payload.
|
132
|
+
#
|
133
|
+
# @return [Hash] The APNS payload.
|
134
|
+
#
|
135
|
+
def payload
|
136
|
+
aps = Hash.new
|
137
|
+
|
138
|
+
if self.title.is_a?(String)
|
139
|
+
aps['alert'] ||= Hash.new
|
140
|
+
aps['alert']['title'] = self.title
|
141
|
+
elsif self.title.is_a?(Gravel::APNS::Notification::Localization)
|
142
|
+
aps['alert'] ||= Hash.new
|
143
|
+
aps['alert'].merge!(self.title.payload(:title))
|
144
|
+
end
|
145
|
+
|
146
|
+
if self.subtitle.is_a?(String)
|
147
|
+
aps['alert'] ||= Hash.new
|
148
|
+
aps['alert']['subtitle'] = self.subtitle
|
149
|
+
elsif self.subtitle.is_a?(Gravel::APNS::Notification::Localization)
|
150
|
+
aps['alert'] ||= Hash.new
|
151
|
+
aps['alert'].merge!(self.subtitle.payload(:subtitle))
|
152
|
+
end
|
153
|
+
|
154
|
+
if self.body.is_a?(String)
|
155
|
+
aps['alert'] ||= Hash.new
|
156
|
+
aps['alert']['body'] = self.body
|
157
|
+
elsif self.body.is_a?(Gravel::APNS::Notification::Localization)
|
158
|
+
aps['alert'] ||= Hash.new
|
159
|
+
aps['alert'].merge!(self.body.payload(:body))
|
160
|
+
end
|
161
|
+
|
162
|
+
if self.sound == :default
|
163
|
+
aps['sound'] = 'default'
|
164
|
+
elsif self.sound.is_a?(String)
|
165
|
+
aps['sound'] = self.sound.to_s
|
166
|
+
end
|
167
|
+
|
168
|
+
if self.badge.is_a?(Integer)
|
169
|
+
aps['badge'] = self.badge
|
170
|
+
end
|
171
|
+
|
172
|
+
if self.action_key.is_a?(String)
|
173
|
+
aps['alert']['action-loc-key'] = self.action_key
|
174
|
+
end
|
175
|
+
|
176
|
+
if self.category.is_a?(String)
|
177
|
+
aps['category'] = self.category
|
178
|
+
end
|
179
|
+
|
180
|
+
if self.launch_image.is_a?(String)
|
181
|
+
aps['alert']['launch-image'] = self.launch_image
|
182
|
+
end
|
183
|
+
|
184
|
+
if self.content_available
|
185
|
+
aps['content-available'] = '1'
|
186
|
+
end
|
187
|
+
|
188
|
+
payload = Hash.new
|
189
|
+
|
190
|
+
if self.mutable_content.is_a?(Hash)
|
191
|
+
aps['mutable-content'] = '1'
|
192
|
+
payload.merge!(self.mutable_content)
|
193
|
+
end
|
194
|
+
|
195
|
+
unless aps.empty?
|
196
|
+
payload['aps'] = aps
|
197
|
+
end
|
198
|
+
|
199
|
+
payload
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Gravel
|
2
|
+
class APNS
|
3
|
+
class Notification
|
4
|
+
# This class can be used to localize part of a notification.
|
5
|
+
# You can set a notification's title, subtitle or body attribute
|
6
|
+
# to an instance of this class to localize it.
|
7
|
+
#
|
8
|
+
class Localization
|
9
|
+
# The localization key as defined in your app's localization file.
|
10
|
+
#
|
11
|
+
# @return [String] The localization key.
|
12
|
+
#
|
13
|
+
attr_reader :key
|
14
|
+
|
15
|
+
# Additional arguments to pass into the localizable string.
|
16
|
+
#
|
17
|
+
# @return [Array] Additional arguments.
|
18
|
+
#
|
19
|
+
attr_accessor :arguments
|
20
|
+
|
21
|
+
# Create a new localization.
|
22
|
+
#
|
23
|
+
# @param key [String] The localization key.
|
24
|
+
# @param args [Splat] Arguments to pass into the localizable string.
|
25
|
+
# @return [Gravel::APNS::Notification::Localization] The localization object.
|
26
|
+
#
|
27
|
+
def initialize(key, *args)
|
28
|
+
unless key.is_a?(String)
|
29
|
+
raise 'The localization key must be a string.'
|
30
|
+
end
|
31
|
+
|
32
|
+
@key = key.to_s
|
33
|
+
self.arguments = args
|
34
|
+
end
|
35
|
+
|
36
|
+
# Set additional arguments to pass into the localizable string.
|
37
|
+
#
|
38
|
+
# @param arguments [Array] Additional arguments.
|
39
|
+
# @return [Array] The input value.
|
40
|
+
#
|
41
|
+
def arguments=(arguments)
|
42
|
+
unless arguments.nil?
|
43
|
+
unless arguments.is_a?(Array)
|
44
|
+
raise 'The localization arguments must be an array.'
|
45
|
+
end
|
46
|
+
|
47
|
+
unless arguments.all? { |a| a.is_a?(Float) || a.is_a?(Integer) || a.is_a?(String) }
|
48
|
+
raise 'The localization arguments must all be primitives.'
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
@arguments = arguments
|
53
|
+
end
|
54
|
+
|
55
|
+
# Check if there are any additional arguments.
|
56
|
+
#
|
57
|
+
# @return [Boolean] Whether or not there are any additional arguments.
|
58
|
+
#
|
59
|
+
def arguments?
|
60
|
+
self.arguments && self.arguments.any?
|
61
|
+
end
|
62
|
+
|
63
|
+
# Convert the localization into APNS payload components.
|
64
|
+
#
|
65
|
+
# @param type [Symbol] The localization type (title/subtitle/body).
|
66
|
+
# @return [Hash] The APNS payload components.
|
67
|
+
#
|
68
|
+
def payload(type)
|
69
|
+
components = Hash.new
|
70
|
+
|
71
|
+
case type
|
72
|
+
when :title
|
73
|
+
components['title-loc-key'] = @key
|
74
|
+
components['title-loc-args'] = self.arguments if arguments?
|
75
|
+
when :subtitle
|
76
|
+
components['subtitle-loc-key'] = @key
|
77
|
+
components['subtitle-loc-args'] = self.arguments if arguments?
|
78
|
+
when :body
|
79
|
+
components['loc-key'] = @key
|
80
|
+
components['loc-args'] = self.arguments if arguments?
|
81
|
+
end
|
82
|
+
|
83
|
+
components
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gravel
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0.alpha1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nialto Services
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-04-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: jwt
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: net-http2
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.14.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.14.1
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.11'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.11'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '10.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '10.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: yard
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 0.9.8
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 0.9.8
|
97
|
+
description:
|
98
|
+
email:
|
99
|
+
- support@nialtoservices.co.uk
|
100
|
+
executables: []
|
101
|
+
extensions: []
|
102
|
+
extra_rdoc_files: []
|
103
|
+
files:
|
104
|
+
- ".gitignore"
|
105
|
+
- ".rspec"
|
106
|
+
- Gemfile
|
107
|
+
- README.md
|
108
|
+
- Rakefile
|
109
|
+
- bin/console
|
110
|
+
- bin/setup
|
111
|
+
- gravel.gemspec
|
112
|
+
- lib/gravel.rb
|
113
|
+
- lib/gravel/apns.rb
|
114
|
+
- lib/gravel/apns/auto_token.rb
|
115
|
+
- lib/gravel/apns/notification.rb
|
116
|
+
- lib/gravel/apns/notification/localization.rb
|
117
|
+
- lib/gravel/constants.rb
|
118
|
+
- spec/spec_helper.rb
|
119
|
+
- spec/unit/gravel/constants_spec.rb
|
120
|
+
homepage: https://github.com/nialtoservices/gravel
|
121
|
+
licenses:
|
122
|
+
- MIT
|
123
|
+
metadata:
|
124
|
+
yard.run: yri
|
125
|
+
post_install_message:
|
126
|
+
rdoc_options: []
|
127
|
+
require_paths:
|
128
|
+
- lib
|
129
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: 1.3.1
|
139
|
+
requirements: []
|
140
|
+
rubyforge_project:
|
141
|
+
rubygems_version: 2.6.11
|
142
|
+
signing_key:
|
143
|
+
specification_version: 4
|
144
|
+
summary: Unified Push Notifications
|
145
|
+
test_files:
|
146
|
+
- spec/spec_helper.rb
|
147
|
+
- spec/unit/gravel/constants_spec.rb
|