gravel 0.1.0.alpha1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|