├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── expo-server-sdk.gemspec ├── lib ├── expo │ └── server │ │ ├── sdk.rb │ │ └── sdk │ │ └── version.rb └── push │ ├── chunk.rb │ ├── client.rb │ ├── notification.rb │ ├── receipts.rb │ └── tickets.rb └── test ├── expo ├── push │ └── client_test.rb └── server │ └── sdk_test.rb └── test_helper.rb /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push,pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Ruby 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: 2.7.4 14 | - name: Run the default task 15 | run: | 16 | gem install bundler -v 2.2.7 17 | bundle install 18 | bundle exec rake 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | /expo-server-sdk-*.gem -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 3 | NewCops: enable 4 | 5 | Style/StringLiterals: 6 | Enabled: true 7 | EnforcedStyle: single_quotes 8 | 9 | Style/StringLiteralsInInterpolation: 10 | Enabled: true 11 | EnforcedStyle: single_quotes 12 | 13 | Layout/EndOfLine: 14 | Enabled: true 15 | EnforcedStyle: lf 16 | 17 | Layout/LineLength: 18 | Max: 120 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.6 4 | 5 | - Fix authentication for authenticated pushes [#6](https://github.com/SleeplessByte/expo-server-sdk-ruby/pull/6) by [@pvign](https://github.com/pvign) 6 | 7 | ## 0.1.5 8 | 9 | - Remove unnecessary logging [#4](https://github.com/SleeplessByte/expo-server-sdk-ruby/pull/4) by [@stefanahman](https://github.com/stefanahman) 10 | 11 | ## 0.1.4 12 | 13 | - Change `ruby` version requirement to major only (2.6+) 14 | 15 | ## 0.1.3 16 | 17 | - Fix typo in README.md 18 | - Change `http` version requirement to be loose (4+) 19 | 20 | ## 0.1.2 21 | 22 | - Add CHANGELOG.md 23 | - Add shields to README.md 24 | - Fix failing outdated tests 25 | 26 | ## 0.1.1 27 | 28 | - Fix various release issues 29 | 30 | ## 0.1.0 31 | 32 | Initial release 33 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at derk-jan+github@karrenbeld.info. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in expo-server-sdk.gemspec 6 | gemspec 7 | 8 | gem 'minitest', '~> 5.0' 9 | gem 'rake', '~> 13.0' 10 | gem 'rubocop', '~> 1.7' 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | expo-server-sdk (0.1.6) 5 | connection_pool (~> 2.2) 6 | http (>= 4.0, < 6.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | addressable (2.8.4) 12 | public_suffix (>= 2.0.2, < 6.0) 13 | ast (2.4.2) 14 | connection_pool (2.4.1) 15 | domain_name (0.5.20190701) 16 | unf (>= 0.0.5, < 1.0.0) 17 | ffi (1.15.5) 18 | ffi (1.15.5-x64-mingw32) 19 | ffi-compiler (1.0.1) 20 | ffi (>= 1.0.0) 21 | rake 22 | http (5.1.1) 23 | addressable (~> 2.8) 24 | http-cookie (~> 1.0) 25 | http-form_data (~> 2.2) 26 | llhttp-ffi (~> 0.4.0) 27 | http-cookie (1.0.5) 28 | domain_name (~> 0.5) 29 | http-form_data (2.3.0) 30 | llhttp-ffi (0.4.0) 31 | ffi-compiler (~> 1.0) 32 | rake (~> 13.0) 33 | minitest (5.14.4) 34 | parallel (1.21.0) 35 | parser (3.0.2.0) 36 | ast (~> 2.4.1) 37 | public_suffix (5.0.1) 38 | rainbow (3.0.0) 39 | rake (13.0.6) 40 | regexp_parser (2.1.1) 41 | rexml (3.3.9) 42 | rubocop (1.22.3) 43 | parallel (~> 1.10) 44 | parser (>= 3.0.0.0) 45 | rainbow (>= 2.2.2, < 4.0) 46 | regexp_parser (>= 1.8, < 3.0) 47 | rexml 48 | rubocop-ast (>= 1.12.0, < 2.0) 49 | ruby-progressbar (~> 1.7) 50 | unicode-display_width (>= 1.4.0, < 3.0) 51 | rubocop-ast (1.13.0) 52 | parser (>= 3.0.1.1) 53 | ruby-progressbar (1.11.0) 54 | unf (0.1.4) 55 | unf_ext 56 | unf_ext (0.0.8.2) 57 | unf_ext (0.0.8.2-x64-mingw32) 58 | unicode-display_width (2.1.0) 59 | 60 | PLATFORMS 61 | x64-mingw32 62 | x86_64-linux 63 | 64 | DEPENDENCIES 65 | expo-server-sdk! 66 | minitest (~> 5.0) 67 | rake (~> 13.0) 68 | rubocop (~> 1.7) 69 | 70 | BUNDLED WITH 71 | 2.2.7 72 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Derk-Jan Karrenbeld 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo::Server::SDK 2 | 3 | [![Build status](https://github.com/SleeplessByte/expo-server-sdk-ruby/actions/workflows/main.yml/badge.svg)](https://github.com/SleeplessByte/expo-server-sdk-ruby/actions/workflows/main.yml) [![Gem version](https://img.shields.io/gem/v/expo-server-sdk?label=gem)](https://rubygems.org/gems/expo-server-sdk) 4 | 5 | This gem was written because of the relatively little attention and improvement [expo-server-sdk-ruby](https://github.com/expo-community/expo-server-sdk-ruby) receives. 6 | 7 | It does **not** work in the same way, so you'll want to read the documentation carefully if you intend to migrate. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'expo-server-sdk' 15 | ``` 16 | 17 | And then execute: 18 | 19 | ```shell 20 | bundle install 21 | ``` 22 | 23 | Or install it yourself as: 24 | 25 | ```shell 26 | gem install expo-server-sdk 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```ruby 32 | # Not necessary in Rails. Zeitwerk will require this correctly for you. 33 | require 'expo/server/sdk' 34 | 35 | # Create a new Expo SDK client optionally providing an access token if you 36 | # have enabled push security 37 | client = Expo::Push::Client.new(access_token: ''); 38 | 39 | # If you do not have an access token, you can call it like this: 40 | # client = Expo::Push::Client.new 41 | 42 | # Create the messages that you want to send to clients 43 | messages = []; 44 | 45 | some_push_tokens.each do |push_token| 46 | # Each push token looks like ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx] 47 | 48 | # Check that all your push tokens appear to be valid Expo push tokens. 49 | # If you don't do this, this library will raise an error when trying to 50 | # create the notification. 51 | # 52 | unless Expo::Push.expo_push_token?(push_token) 53 | puts "Push token #{pushToken} is not a valid Expo push token" 54 | next 55 | end 56 | 57 | # Construct a message (see https://docs.expo.io/push-notifications/sending-notifications/) 58 | # 59 | # Use client.notification, Expo::Push::Notification.new, 60 | # or Expo::Push::Notification.to, then follow it with one or more chainable 61 | # API calls, including, but not limited to: 62 | # 63 | # - #to: add recipient (or #add_recipient), 64 | # add recipients (or #add_recipients) 65 | # - #title 66 | # - #subtitle 67 | # - #body (or #content) 68 | # - #data 69 | # - #priority 70 | # - #sound 71 | # - #channel_id 72 | # - #category_id 73 | # 74 | messages << client.notification 75 | .to(push_token) 76 | .sound('default') 77 | .body('This is a test notification') 78 | .data({ withSome: 'data' }) 79 | end 80 | 81 | # The Expo push notification service accepts batches of notifications so that 82 | # you don't need to send 1000 requests to send 1000 notifications. We 83 | # recommend you batch your notifications to reduce the number of requests and 84 | # to compress them (notifications with similar content will get compressed). 85 | # 86 | # Using #send or #send! will automatically batch your messages. 87 | # 88 | # When using #send, the result is an array of tickets per batched chunk, or may 89 | # be an error, such as a TicketsWithErrors error. It's up to you to inspect and 90 | # handle those errors. 91 | # 92 | # When using #send!, all batches will first execute, and then the first error 93 | # received is raised. 94 | # 95 | tickets = client.send!(messages) 96 | 97 | # You can #explain(error) to attempt to explain nested errors. For example, say 98 | # a batch contains failed errors, or completely failed pages: 99 | # 100 | tickets.each_error do |error| 101 | if error.respond_to?(:explain) 102 | puts error.explain 103 | # => "The device cannot receive push notifications anymore and you should 104 | # stop sending messages to the corresponding Expo push token." 105 | 106 | puts error.message 107 | # => ""ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx]" is not a registered push 108 | # notification recipient" 109 | # 110 | # In the case of an DeviceNotRegistered, you can attempt to extract the 111 | # faulty push token: 112 | 113 | error.original_push_token 114 | # => ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx] 115 | else 116 | puts error.message 117 | # => "This indicates the entire request had an error" 118 | end 119 | end 120 | 121 | # Later, after the Expo push notification service has delivered the 122 | # notifications to Apple or Google (usually quickly, but allow the the service 123 | # up to 30 minutes when under load), a "receipt" for each notification is 124 | # created. The receipts will be available for at least a day; stale receipts 125 | # are deleted. 126 | # 127 | # The ID of each receipt is sent back in the response "ticket" for each 128 | # notification. In summary, sending a notification produces a ticket, which 129 | # contains a receipt ID you later use to get the receipt. 130 | # 131 | # The receipts may contain error codes to which you must respond. In 132 | # particular, Apple or Google may block apps that continue to send 133 | # notifications to devices that have blocked notifications or have uninstalled 134 | # your app. Expo does not control this policy and sends back the feedback from 135 | # Apple and Google so you can handle it appropriately. 136 | # 137 | # Note: this will silently skip over any errors encountered. Use #each_error 138 | # to attempt to handle them yourself. 139 | receipt_ids = tickets.ids 140 | 141 | # You may want to be doing this in some job context, so this gem doesn't batch 142 | # and call the endpoint manually, but you can generate the batches, and send 143 | # then individually: 144 | batches = tickets.batch_ids 145 | 146 | # Now you can schedule your jobs, thread, or run this inline. All would work. 147 | batches.each do |receipt_ids| 148 | # << schedule a job with this batch of ids >> 149 | # ... 150 | # inside the job or inline 151 | receipts = client.receipts(receipt_ids) 152 | 153 | # You can #explain(error) to attempt to explain receipts that have an 154 | # error status. 155 | # 156 | receipts.each_error do |receipt| 157 | puts error.explain 158 | # => "The device cannot receive push notifications anymore and you should 159 | # stop sending messages to the corresponding Expo push token." 160 | 161 | puts error.message 162 | # => ""ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx]" is not a registered push 163 | # notification recipient" 164 | # 165 | # In the case of an DeviceNotRegistered, you can attempt to extract the 166 | # faulty push token: 167 | 168 | error.original_push_token 169 | # => ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx] 170 | end 171 | 172 | # Because not all receipts may be returned, it is imported to schedule, or 173 | # retry the unresolved receipts at a later point in time: 174 | unresolved_ids = receipts.unresolved_ids 175 | 176 | # ... 177 | receipts = client.receipts(unresolved_ids) if unresolved_ids.length > 0 178 | end 179 | ``` 180 | 181 | ### Logging 182 | 183 | It is very likely that you'll want to develop with logging turned on. 184 | This can be accomplished by passing in a logger instance: 185 | 186 | ```ruby 187 | require 'logger' 188 | 189 | logger = Logger.new(STDOUT); 190 | client = Expo::Push::Client.new(logger: logger) 191 | 192 | # Now when doing requests like so: 193 | client.send(notification) 194 | 195 | # ...it will log 196 | # 197 | # I, [2021-10-25T02:16:11.284901 #16448] INFO -- : > POST https://exp.host/--/api/v2/push/send 198 | # D, [2021-10-25T02:16:11.285601 #16448] DEBUG -- : Accept: application/json 199 | # Accept-Encoding: gzip 200 | # User-Agent: expo-server-sdk-ruby/0.1.0 201 | # Connection: Keep-Alive 202 | # Content-Type: application/json; charset=UTF-8 203 | # Host: exp.host 204 | # 205 | # [{"to":["ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx]"]}] 206 | ``` 207 | 208 | For more advanced logging, or instrumentation in general, use the Instrumentation feature. 209 | It expects an `ActiveSupport::Notifications`-compatible instrumenter. 210 | 211 | These are available in most Rails projects by default. 212 | 213 | ```ruby 214 | ActiveSupport::Notifications.subscribe('start_request.http') do |name, start, finish, id, payload| 215 | pp :name => name, :start => start.to_f, :finish => finish.to_f, :id => id, :payload => payload 216 | end 217 | 218 | client = Expo::Push::Client.new(instrumentation: true) 219 | 220 | # Now when doing requests like so: 221 | client.send(notification) 222 | 223 | # ...it will instrument 224 | # => {name: .., start: .., finish: .., id: .., payload: ..} 225 | ``` 226 | 227 | You can configure the namespace (and instrumentation): 228 | 229 | ```ruby 230 | client = Expo::Push::Client.new( 231 | instrumentation: { 232 | instrumenter: ActiveSupport::Notifications.instrumenter, 233 | namespace: "my_http" 234 | } 235 | ) 236 | ``` 237 | 238 | ### Example of error handling 239 | 240 | Here is an example of error handling when using Rails, given a Rails model called `PushNotificationToken`. 241 | 242 | The most important thing is that you remove push tokens that are invalid, you fix push tokens that don't have the right experience ID and you stop sending push notifications if you're not allowed (e.g. the device is no longer registered). 243 | 244 | ```ruby 245 | # Remove invalid push notification tokens, and remove tokens that failed 246 | # and contain a token (DeviceNotRegistered) 247 | tickets.each_error do |error| 248 | 249 | if error.is_a?(Expo::Push::PushTokenInvalid) 250 | # Destroy the tokens that match because they are not valid 251 | PushNotificationToken.where(push_token: error.token).destroy_all 252 | 253 | elsif error.is_a?(Expo::Push::TicketsWithErrors) 254 | retryable = true 255 | 256 | error.errors.each do |error_data| 257 | 258 | # This block tries to fix the token experiences, and then reschedules 259 | # the job. When it fixes tokens, it notifies bugsnag, so we know that 260 | # this happened. If it keeps happening, there is a bug in the query 261 | # or registration code. 262 | if error_data['code'] == "PUSH_TOO_MANY_EXPERIENCE_IDS" 263 | 264 | # Go through all the details 265 | error_data['details'].each do |correct_experience, tokens| 266 | 267 | # Find the incorrect instances 268 | instances = PushNotificationToken 269 | .where.not(experience_id: correct_experience) 270 | .where(push_token: tokens) 271 | 272 | next if instances.blank? 273 | next unless instances.update_all(experience_id: correct_experience) 274 | 275 | instances.each do |instance| 276 | Bugsnag.notify( 277 | StandardError.new( 278 | format( 279 | 'When trying to push, a push token (token: %s) had the wrong experience id (old: %s). ' \ 280 | 'It has been updated (%s).', 281 | instance.push_token, 282 | instance.experience_id_was, 283 | instance.experience_id 284 | ) 285 | ) 286 | ) 287 | end 288 | end 289 | 290 | # If there is a different error, report to our error tracker 291 | else 292 | retryable = false 293 | # Otherwise, notify as actual error. 294 | Bugsnag.notify(error_data) 295 | end 296 | end 297 | 298 | if retries > 10 299 | return Bugsnag.notify( 300 | StandardError.new( 301 | 'Not sending push notification because it was retried > 10 times.' 302 | ) 303 | ) 304 | end 305 | 306 | # If the error is not a fatal one, the push can be retried. This helps 307 | # with making sure you always send the push notification(s) even when 308 | # the service intermittendly fails. 309 | if retryable 310 | ScheduledPushNotificationJob 311 | .set(wait: 1.minute * (retries + 1)) 312 | .perform_later( 313 | notification: notification, 314 | event: event, 315 | updated_at: updated_at, 316 | retries: retries + 1 317 | ) 318 | end 319 | 320 | # Otherwise it's an explanable error 321 | elsif error.respond_to?(:explain) 322 | 323 | # If the error contains a token it always needs to be removed 324 | original_token = error.original_push_token 325 | next unless original_token 326 | 327 | PushNotificationToken.where(push_token: original_token).destroy_all 328 | else 329 | 330 | # Notify us of any other type of error 331 | Bugsnag.notify(error) 332 | end 333 | end 334 | 335 | ``` 336 | 337 | ## Development 338 | 339 | After checking out the repo, run `bin/setup` to install dependencies. 340 | Then, run `rake test` to run the tests. 341 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 342 | 343 | To install this gem onto your local machine, run `bundle exec rake install`. 344 | To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 345 | 346 | ## Contributing 347 | 348 | Bug reports and pull requests are welcome on GitHub at . 349 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/SleeplessByte/expo-server-sdk-ruby/blob/main/CODE_OF_CONDUCT.md). 350 | 351 | ## License 352 | 353 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 354 | 355 | ## Code of Conduct 356 | 357 | Everyone interacting in the `Expo::Server::SDK` project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/SleeplessByte/expo-server-sdk/blob/main/CODE_OF_CONDUCT.md). 358 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << 'test' 8 | t.libs << 'lib' 9 | t.test_files = FileList['test/**/*_test.rb'] 10 | end 11 | 12 | require 'rubocop/rake_task' 13 | 14 | RuboCop::RakeTask.new 15 | 16 | task default: %i[test rubocop] 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'expo/server/sdk' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /expo-server-sdk.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/expo/server/sdk/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'expo-server-sdk' 7 | spec.version = Expo::Server::SDK::VERSION 8 | spec.authors = ['Derk-Jan Karrenbeld'] 9 | spec.email = ['derk-jan+github@karrenbeld.info'] 10 | 11 | spec.summary = 'Modern replacement for exponent-server-sdk' 12 | spec.description = 'This gem has been written to fix shortcomings with the current community provided gem, which ' \ 13 | 'has many outstanding issues and open pull requests.' 14 | spec.homepage = 'https://github.com/sleeplessbyte/expo-server-sdk-ruby' 15 | spec.license = 'MIT' 16 | spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0') 17 | 18 | # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'" 19 | 20 | spec.metadata['homepage_uri'] = spec.homepage 21 | spec.metadata['source_code_uri'] = spec.homepage 22 | spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" 23 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 24 | 25 | # Specify which files should be added to the gem when it is released. 26 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 27 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 28 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } 29 | end 30 | spec.bindir = 'exe' 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ['lib'] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | # spec.add_dependency "example-gem", "~> 1.0" 36 | spec.add_dependency 'connection_pool', '~> 2.2' 37 | spec.add_dependency 'http', '>= 4.0', '< 6.0' 38 | 39 | # For more information and examples about making a new gem, checkout our 40 | # guide at: https://bundler.io/guides/creating_gem.html 41 | end 42 | -------------------------------------------------------------------------------- /lib/expo/server/sdk.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'sdk/version' 4 | require_relative '../../push/client' 5 | 6 | module Expo 7 | module Server 8 | module SDK # rubocop:disable Style/Documentation 9 | def self.push 10 | Expo::Push::Client 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/expo/server/sdk/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Expo 4 | module Server 5 | module SDK 6 | VERSION = '0.1.6' 7 | end 8 | end 9 | 10 | module Push 11 | class Client 12 | VERSION = Expo::Server::SDK::VERSION 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/push/chunk.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Expo 4 | module Push 5 | class Chunk # rubocop:disable Style/Documentation 6 | def self.for(notifications) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 7 | Array(notifications).each_with_object([]) do |notification, chunks| 8 | # There can be at most n notifications in a chunk. This finds the last chunk, 9 | # checks how much space is left, and generates a new chunk if necessary. 10 | chunk = chunks.last || Chunk.new.tap { |c| chunks << c } 11 | 12 | targets = notification.recipients.dup 13 | 14 | while targets.length.positive? 15 | chunk = Chunk.new.tap { |c| chunks << c } if chunk.remaining <= 0 16 | 17 | # Take at most destinations for this notificiation. 18 | count = [targets.length, chunk.remaining].min 19 | chunk_targets = targets.slice(0, count) 20 | 21 | # Prepare the notification 22 | chunk << notification.prepare(chunk_targets) 23 | 24 | # Remove targets from the targets list 25 | targets = targets.drop(count) 26 | end 27 | end 28 | end 29 | 30 | attr_reader :remaining 31 | 32 | def initialize 33 | self.notifications = [] 34 | self.remaining = PUSH_NOTIFICATION_CHUNK_LIMIT 35 | end 36 | 37 | def <<(notification) 38 | self.remaining -= notification.count 39 | notifications << notification 40 | 41 | self 42 | end 43 | 44 | def count 45 | notifications.sum(&:count) 46 | end 47 | 48 | def as_json 49 | notifications.map(&:as_json) 50 | end 51 | 52 | private 53 | 54 | attr_accessor :notifications 55 | attr_writer :remaining 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/push/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'connection_pool' 4 | require 'http' 5 | 6 | require_relative './chunk' 7 | require_relative './notification' 8 | require_relative './receipts' 9 | require_relative './tickets' 10 | 11 | module Expo 12 | module Push # rubocop:disable Style/Documentation 13 | class Error < StandardError # rubocop:disable Style/Documentation 14 | def self.explain(error) # rubocop:disable Metrics/MethodLength 15 | identifier = error.is_a?(String) ? error : error.fetch('details').fetch('error') 16 | 17 | case identifier 18 | when 'DeviceNotRegistered' 19 | 'The device cannot receive push notifications anymore and you' \ 20 | ' should stop sending messages to the corresponding Expo push token.' 21 | when 'InvalidCredentials' 22 | 'Your push notification credentials for your standalone app are ' \ 23 | 'invalid (ex: you may have revoked them). Run expo build:ios -c ' \ 24 | 'to regenerate new push notification credentials for iOS. If you ' \ 25 | 'revoke an APN key, all apps that rely on that key will no longer ' \ 26 | 'be able to send or receive push notifications until you upload a ' \ 27 | 'new key to replace it. Uploading a new APN key will not change ' \ 28 | 'your users\' Expo Push Tokens.' 29 | when 'MessageTooBig' 30 | 'The total notification payload was too large. On Android and iOS ' \ 31 | 'the total payload must be at most 4096 bytes.' 32 | when 'MessageRateExceeded' 33 | 'You are sending messages too frequently to the given device. ' \ 34 | 'Implement exponential backoff and slowly retry sending messages.' 35 | else 36 | "There is no embedded explanation for #{identifier}. Sorry!" 37 | end 38 | rescue KeyError 39 | 'There is no identifier given to explain' 40 | end 41 | end 42 | 43 | class ServerError < Error; end 44 | 45 | class ArgumentError < Error; end 46 | 47 | class TicketsWithErrors < Error # rubocop:disable Style/Documentation 48 | attr_reader :data, :errors 49 | 50 | def initialize(errors:, data:) 51 | self.data = data 52 | self.errors = errors 53 | 54 | if errors.length.zero? 55 | super 'Expected at least one error, but got none' 56 | return 57 | end 58 | 59 | puts errors 60 | 61 | super "Expo indicated one or more problems: #{errors.map { |error| error['message'] }}" 62 | end 63 | 64 | private 65 | 66 | attr_writer :data, :errors 67 | end 68 | 69 | class TicketsExpectationFailed < Error # rubocop:disable Style/Documentation 70 | attr_reader :data 71 | 72 | def initialize(expected_count:, data:) 73 | self.data = data 74 | 75 | super format( 76 | "Expected %s ticket#{if expected_count != 1 77 | 's' 78 | end}, actual: %s. The response data can be inspected.", 79 | count: expected_count, 80 | actual: data.is_a?(Array) ? data.length : '' 81 | ) 82 | end 83 | 84 | private 85 | 86 | attr_writer :data 87 | end 88 | 89 | class ReceiptsWithErrors < Error # rubocop:disable Style/Documentation 90 | attr_reader :data, :errors 91 | 92 | def initialize(errors:, data:) 93 | self.data = data 94 | self.errors = errors 95 | 96 | if errors.length.zero? 97 | super 'Expected at least one error, but got none' 98 | return 99 | end 100 | 101 | super "Expo indicated one or more problems: #{errors.map { |error| error[:message] }}" 102 | end 103 | 104 | private 105 | 106 | attr_writer :data, :errors 107 | end 108 | 109 | class PushTokenInvalid < Error # rubocop:disable Style/Documentation 110 | attr_reader :token 111 | 112 | def initialize(token:) 113 | self.token = token 114 | 115 | super "Expected a valid Expo Push Token, actual: #{token}" 116 | end 117 | 118 | private 119 | 120 | attr_writer :token 121 | end 122 | 123 | ## 124 | # The max number of push notifications to be sent at once. Since we can't automatically upgrade 125 | # everyone using this library, we should strongly try not to decrease it. 126 | # 127 | PUSH_NOTIFICATION_CHUNK_LIMIT = 100 128 | 129 | ## 130 | # The max number of push notification receipts to request at once. 131 | # 132 | PUSH_NOTIFICATION_RECEIPT_CHUNK_LIMIT = 300 133 | 134 | ## 135 | # The default max number of concurrent HTTP requests to send at once and spread out the load, 136 | # increasing the reliability of notification delivery. 137 | # 138 | DEFAULT_CONCURRENT_REQUEST_LIMIT = 6 139 | 140 | BASE_URL = 'https://exp.host' 141 | BASE_API_URL = '/--/api/v2' 142 | 143 | PUSH_API_URL = "#{BASE_API_URL}/push/send" 144 | RECEIPTS_API_URL = "#{BASE_API_URL}/push/getReceipts" 145 | 146 | ## 147 | # Returns `true` if the token is an Expo push token 148 | # 149 | def self.expo_push_token?(token) 150 | return false unless token 151 | 152 | /\AExpo(?:nent)?PushToken\[[^\]]+\]\z/.match?(token) || 153 | /\A[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}\z/i.match?(token) 154 | end 155 | 156 | ## 157 | # This is the Push Client for Expo's Push Service. It is responsible for 158 | # sending the notifications themselves as well as retrieving the receipts. 159 | # 160 | # It will attempt to keep a persistent connection once the first request is 161 | # made, and allow at most {concurrency} concurrent requests. 162 | # 163 | class Client 164 | def initialize( 165 | access_token: nil, 166 | concurrency: DEFAULT_CONCURRENT_REQUEST_LIMIT, 167 | logger: false, 168 | instrumentation: false 169 | ) 170 | self.access_token = access_token 171 | self.concurrency = concurrency 172 | self.logger = logger 173 | self.instrumentation = if instrumentation == true 174 | { instrumentation: ActiveSupport::Notifications.instrumenter } 175 | else 176 | instrumentation 177 | end 178 | end 179 | 180 | # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize 181 | def send(notifications) 182 | connect unless pool? 183 | 184 | threads = Chunk.for(notifications).map do |chunk| 185 | expected_count = chunk.count 186 | 187 | Thread.new do 188 | pool.with do |http| 189 | response = http.post(PUSH_API_URL, json: chunk.as_json) 190 | parsed_response = response.parse 191 | 192 | data = parsed_response['data'] 193 | errors = parsed_response['errors'] 194 | 195 | if errors&.length&.positive? 196 | TicketsWithErrors.new(data: data, errors: errors) 197 | elsif !data.is_a?(Array) || data.length != expected_count 198 | TicketsExpectationFailed.new(expected_count: expected_count, data: data) 199 | else 200 | data.map { |ticket| Ticket.new(ticket) } 201 | end 202 | end 203 | end 204 | end 205 | 206 | Tickets.new(threads.map(&:value)) 207 | end 208 | # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize 209 | 210 | def send!(notifications) 211 | send(notifications).tap do |result| 212 | result.each_error do |error| 213 | raise error if error.is_a?(Error) 214 | end 215 | end 216 | end 217 | 218 | # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize 219 | def receipts(receipt_ids) 220 | connect unless pool? 221 | 222 | pool.with do |http| 223 | response = http.post(RECEIPTS_API_URL, json: { ids: Array(receipt_ids) }) 224 | parsed_response = response.parse 225 | 226 | if !parsed_response || parsed_response.is_a?(Array) || !parsed_response.is_a?(Hash) 227 | raise ServerError, 'Expected hash with receipt id => receipt, but got some other data structure' 228 | end 229 | 230 | errors = parsed_response['errors'] 231 | data = parsed_response['data'] 232 | 233 | if errors&.length&.positive? 234 | ReceiptsWithErrors.new(data: parsed_response, errors: errors) 235 | else 236 | results = data.map do |receipt_id, receipt_value| 237 | Receipt.new(data: receipt_value, receipt_id: receipt_id) 238 | end 239 | 240 | Receipts.new(results: results, requested_ids: receipt_ids) 241 | end 242 | end 243 | end 244 | # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize 245 | 246 | def connect # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 247 | shutdown 248 | 249 | self.pool = ConnectionPool.new(size: concurrency, timeout: 5) do 250 | http = HTTP.headers( 251 | # All request should return JSON (in this client) 252 | Accept: 'application/json', 253 | # All responses are allowed to be gzip-encoded 254 | 'Accept-Encoding': 'gzip', 255 | # Set user-agent so that expo can track usage 256 | 'User-Agent': format('expo-server-sdk-ruby/%s', version: VERSION) 257 | ) 258 | 259 | http = http.auth("Bearer #{access_token}") if access_token 260 | 261 | # All requests are allowed to automatically gzip 262 | http = http.use(:auto_inflate) 263 | # Turn on logging if there is a logger 264 | http = http.use(logging: { logger: logger }) if logger 265 | # Turn on instrumentation 266 | http = http.use(instrumentation: instrumentation) if instrumentation 267 | 268 | http.persistent(BASE_URL) 269 | end 270 | end 271 | 272 | def shutdown 273 | return unless pool? 274 | 275 | pool.shutdown do |conn| 276 | conn&.close 277 | end 278 | end 279 | 280 | def notification 281 | Expo::Push::Notification.new 282 | end 283 | 284 | private 285 | 286 | attr_accessor :access_token, :concurrency, :pool, :logger, :instrumentation 287 | 288 | def pool? 289 | !!pool 290 | end 291 | end 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /lib/push/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Expo 4 | module Push 5 | ## 6 | # Data model for PushNotification. 7 | # 8 | class Notification # rubocop:disable Metrics/ClassLength 9 | attr_accessor :recipients 10 | 11 | def self.to(recipient) 12 | new.to(recipient) 13 | end 14 | 15 | def initialize(_recipient = []) 16 | self.recipients = [] 17 | self._params = {} 18 | end 19 | 20 | ## 21 | # Set or add recipient or recipients. 22 | # 23 | # Must be a valid Expo Push Token, or array-like / enumerator that yield 24 | # valid Expo Push Tokens, or an PushTokenInvalid error is raised. 25 | # 26 | # @see PushTokenInvalid 27 | # @see #<< 28 | # 29 | def to(recipient_or_multiple) 30 | Array(recipient_or_multiple).each do |recipient| 31 | self << recipient 32 | end 33 | 34 | self 35 | rescue NoMethodError 36 | raise ArgumentError, 'to must be a single Expo Push Token, or an array-like/enumerator of Expo Push Tokens' 37 | end 38 | 39 | ## 40 | # Set or overwrite the data. 41 | # 42 | # Data must be a Hash, or at least be JSON serializable as hash. 43 | # 44 | # A JSON object delivered to your app. It may be up to about 4KiB; the 45 | # total notification payload sent to Apple and Google must be at most 46 | # 4KiB or else you will get a "Message Too Big" error. 47 | # 48 | def data(value) 49 | json_data = value.respond_to?(:as_json) ? value.as_json : value.to_h 50 | 51 | raise ArgumentError, 'data must be hash-like or nil' if !json_data.nil? && !json_data.is_a?(Hash) 52 | 53 | _params[:data] = json_data 54 | self 55 | rescue NoMethodError 56 | raise ArgumentError, 'data must be hash-like, respond to as_json, or nil' 57 | end 58 | 59 | ## 60 | # Set or overwrite the title. 61 | # 62 | # The title to display in the notification. Often displayed above the 63 | # notification body. 64 | # 65 | def title(value) 66 | _params[:title] = value.nil? ? nil : String(value) 67 | self 68 | rescue NoMethodError 69 | raise ArgumentError, 'title must be nil or string-like' 70 | end 71 | 72 | ## 73 | # Set or overwrite the subtitle. 74 | # 75 | # The subtitle to display in the notification below the title. 76 | # 77 | # @note iOS only 78 | # 79 | def subtitle(value) 80 | _params[:subtitle] = value.nil? ? nil : String(value) 81 | self 82 | rescue NoMethodError 83 | raise ArgumentError, 'subtitle must be nil or string-like' 84 | end 85 | 86 | alias sub_title subtitle 87 | 88 | ## 89 | # Set or overwrite the body (content). 90 | # 91 | # The message to display in the notification. 92 | # 93 | def body(value) 94 | _params[:body] = value.nil? ? nil : String(value) 95 | self 96 | rescue NoMethodError 97 | raise ArgumentError, 'body must be nil or string-like' 98 | end 99 | 100 | alias content body 101 | 102 | ## 103 | # Set or overwrite the sound. 104 | # 105 | # Play a sound when the recipient receives this notification. Specify 106 | # "default" to play the device's default notification sound, or nil to 107 | # play no sound. Custom sounds are not supported. 108 | # 109 | # @note iOS only 110 | # 111 | # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 112 | def sound(value) 113 | if value.nil? 114 | _params[:sound] = nil 115 | return self 116 | end 117 | 118 | unless value.respond_to?(:to_h) 119 | _params[:sound] = String(value) 120 | return self 121 | end 122 | 123 | json_value = value.to_h 124 | 125 | next_value = { 126 | critical: !json_value.fetch(:critical, nil).nil?, 127 | name: json_value.fetch(:name, nil), 128 | volume: json_value.fetch(:volume, nil) 129 | } 130 | 131 | next_value[:name] = String(next_value[:name]) unless next_value[:name].nil? 132 | next_value[:volume] = next_value[:volume].to_i unless next_value[:volume].nil? 133 | 134 | _params[:sound] = next_value.compact 135 | 136 | self 137 | end 138 | # rubocop:enable Metrics/MethodLength, Metrics/AbcSize 139 | 140 | ## 141 | # Set or overwrite the time to live in seconds. 142 | # 143 | # The number of seconds for which the message may be kept around for 144 | # redelivery if it hasn't been delivered yet. Defaults to nil in order to 145 | # use the respective defaults of each provider: 146 | # 147 | # - 0 for iOS/APNs 148 | # - 2419200 (4 weeks) for Android/FCM 149 | # 150 | # @see expiration 151 | # 152 | # @note On Android, we make a best effort to deliver messages with zero 153 | # TTL immediately and do not throttle them. However, setting TTL to a 154 | # low value (e.g. zero) can prevent normal-priority notifications from 155 | # ever reaching Android devices that are in doze mode. In order to 156 | # guarantee that a notification will be delivered, TTL must be long 157 | # enough for the device to wake from doze mode. 158 | # 159 | def ttl(value) 160 | _params[:ttl] = value.nil? ? nil : value.to_i 161 | self 162 | rescue NoMethodError 163 | raise ArgumentError, 'ttl must be numeric or nil' 164 | end 165 | 166 | ## 167 | # Set or overwrite the time to live based on a unix timestamp. 168 | # 169 | # Timestamp since the UNIX epoch specifying when the message expires. 170 | # Same effect as ttl (ttl takes precedence over expiration). 171 | # 172 | # @see ttl 173 | # 174 | def expiration(value) 175 | _params[:expiration] = value.nil? ? nil : value.to_i 176 | self 177 | rescue NoMethodError 178 | raise ArgumentError, 'ttl must be numeric or nil' 179 | end 180 | 181 | ## 182 | # Set or overwrite the priority. 183 | # 184 | # The delivery priority of the message. Specify "default" or nil to use 185 | # the default priority on each platform: 186 | # 187 | # - "normal" on Android 188 | # - "high" on iOS 189 | # 190 | # @note On Android, normal-priority messages won't open network 191 | # connections on sleeping devices and their delivery may be delayed to 192 | # conserve the battery. High-priority messages are delivered 193 | # immediately if possible and may wake sleeping devices to open network 194 | # connections, consuming energy. 195 | # 196 | # @note On iOS, normal-priority messages are sent at a time that takes 197 | # into account power considerations for the device, and may be grouped 198 | # and delivered in bursts. They are throttled and may not be delivered 199 | # by Apple. High-priority messages are sent immediately. 200 | # Normal priority corresponds to APNs priority level 5 and high 201 | # priority to 10. 202 | # 203 | # rubocop:disable Metrics/MethodLength 204 | def priority(value) 205 | if value.nil? 206 | _params[:priority] = nil 207 | return self 208 | end 209 | 210 | priority_string = String(value) 211 | 212 | unless %w[default normal high].include?(priority_string) 213 | raise ArgumentError, 'priority must be default, normal, or high' 214 | end 215 | 216 | _params[:priority] = priority_string 217 | self 218 | rescue NoMethodError 219 | raise ArgumentError, 'priority must be default, normal, or high' 220 | end 221 | # rubocop:enable Metrics/MethodLength 222 | 223 | ## 224 | # Set or overwrite the new badge count. 225 | # 226 | # Use 0 to clear, use nil to keep as is. 227 | # 228 | # @note iOS only 229 | # 230 | def badge(value) 231 | _params[:badge] = value.nil? ? nil : value.to_i 232 | self 233 | rescue NoMethodError 234 | raise ArgumentError, 'badge must be numeric or nil' 235 | end 236 | 237 | ## 238 | # Set or overwrite the channel ID. 239 | # 240 | # ID of the Notification Channel through which to display this 241 | # notification. If an ID is specified but the corresponding channel does 242 | # not exist on the device (i.e. has not yet been created by your app), 243 | # the notification will not be displayed to the user. 244 | # 245 | # @note If left nil, a "Default" channel will be used, and Expo will 246 | # create the channel on the device if it does not yet exist. However, 247 | # use caution, as the "Default" channel is user-facing and you may not 248 | # be able to fully delete it. 249 | # 250 | # @note Android only 251 | # 252 | def channel_id(value) 253 | _params[:channelId] = value.nil? ? nil : String(value) 254 | self 255 | rescue NoMethodError 256 | raise ArgumentError, 'channelId must be string-like or nil to use "Default"' 257 | end 258 | 259 | alias channel_identifier channel_id 260 | 261 | ## 262 | # Set or overwrite the category ID 263 | # 264 | # ID of the notification category that this notification is associated 265 | # with. Must be on at least SDK 41 or bare workflow. 266 | # 267 | # Notification categories allow you to create interactive push 268 | # notifications, so that a user can respond directly to the incoming 269 | # notification either via buttons or a text response. A category defines 270 | # the set of actions a user can take, and then those actions are applied 271 | # to a notification by specifying the categoryId here. 272 | # 273 | # @see https://docs.expo.dev/versions/latest/sdk/notifications/#managing-notification-categories-interactive-notifications 274 | # 275 | def category_id(value) 276 | _params[:categoryId] = value.nil? ? nil : String(value) 277 | self 278 | rescue NoMethodError 279 | raise ArgumentError, 'categoryId must be string-like or nil' 280 | end 281 | 282 | ## 283 | # Set or overwrite content available. 284 | # 285 | # Must be a boolean or nil, or an ArgumentError is raised. 286 | # 287 | # On iOS, use this to trigger a background fetch action. 288 | # Under normal circumstances, the "content-available" flag should launch 289 | # your app if it isn't running and wasn't killed by the user. However, 290 | # this is ultimately decided by the OS, so it might not always happen. 291 | # 292 | def content_available(value) 293 | raise ArgumentError, 'content_available must be boolean or nil' unless [true, false, nil].include?(value) 294 | 295 | _params[:contentAvailable] = value 296 | self 297 | end 298 | 299 | ## 300 | # Set or overwrite the mutability flag. 301 | # 302 | # Use nil to use the defaults. 303 | # 304 | # Specifies whether this notification can be intercepted by the client 305 | # app. In Expo Go, this defaults to true, and if you change that to 306 | # false, you may experience issues. In standalone and bare apps, this 307 | # defaults to false. 308 | # 309 | def mutable_content(value) 310 | _params[:mutableContent] = value.nil? ? nil : !value.nil? 311 | self 312 | end 313 | 314 | alias mutable mutable_content 315 | 316 | ## 317 | # Add a single recipient 318 | # 319 | # Must be a valid Expo Push Token, or a PushTokenInvalid error is raised. 320 | # 321 | # @see PushTokenInvalid 322 | # @see #to 323 | # 324 | def <<(recipient) 325 | raise PushTokenInvalid.new(token: recipient) unless Expo::Push.expo_push_token?(recipient) 326 | 327 | recipients << recipient 328 | 329 | self 330 | end 331 | 332 | alias add_recipient << 333 | alias add_recipients to 334 | 335 | ## 336 | # Allows overwriting the recipients list which is necessary to prepare 337 | # the notification when chunking. 338 | # 339 | def prepare(targets) 340 | dup.tap do |prepared| 341 | prepared.reset_recipients(targets) 342 | end 343 | end 344 | 345 | def count 346 | recipients.length 347 | end 348 | 349 | def as_json 350 | { to: recipients }.merge(_params.compact) 351 | end 352 | 353 | def reset_recipients(targets) 354 | self.recipients = [] 355 | add_recipients(targets) 356 | end 357 | 358 | private 359 | 360 | attr_accessor :_params 361 | end 362 | end 363 | end 364 | -------------------------------------------------------------------------------- /lib/push/receipts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Expo 4 | module Push 5 | ## 6 | # A single receipt for a single notification. 7 | # 8 | # - In case of an #ok? receipt, no action need be taken 9 | # - In case of an #error? receipt, holds the #message, #explain 10 | # 11 | # Some failed receipts may expose which push token is not or no longer 12 | # valid. This is exposed via #original_push_token. 13 | # 14 | class Receipt 15 | attr_reader :data, :receipt_id 16 | 17 | def initialize(data:, receipt_id:) 18 | self.data = data 19 | self.receipt_id = receipt_id 20 | end 21 | 22 | def original_push_token 23 | return nil if ok? 24 | 25 | if message.include?('PushToken[') 26 | return /Expo(?:nent)?PushToken\[(?:[^\]]+?)\]/.match(message) { |match| match[0] } 27 | end 28 | 29 | /\A[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}\z/i.match(message) { |match| match[0] } 30 | end 31 | 32 | def message 33 | data.fetch('message') 34 | end 35 | 36 | def explain 37 | Expo::Push::Error.explain((data['details'] || {})['error']) 38 | end 39 | 40 | def ok? 41 | data['status'] == 'ok' 42 | end 43 | 44 | def error? 45 | data['status'] == 'error' 46 | end 47 | 48 | private 49 | 50 | attr_writer :data, :receipt_id 51 | end 52 | 53 | ## 54 | # Receipts represent a single call to the receipts endpoint. It holds both 55 | # the successfully retrieved receipts, as well as the still unresolved IDs. 56 | # 57 | # You MUST iterate #each_error and first check if its an Error, which would 58 | # be the case if the entire call failed. Otherwise, it will iterate through 59 | # each receipt that indicates a failed push. 60 | # 61 | # Keep calling the receipts endpoint until #unresolved_ids is empty, or a 62 | # day has passed at least. 63 | # 64 | # @see Receipt 65 | # 66 | class Receipts 67 | def initialize(results:, requested_ids:) 68 | self.results = results 69 | self.requested_ids = requested_ids 70 | end 71 | 72 | def each 73 | results.each do |receipt| 74 | next unless receipt.ok? 75 | 76 | yield receipt 77 | end 78 | end 79 | 80 | def each_error 81 | results.each do |receipt| 82 | next yield receipt if receipt.is_a?(Error) 83 | next unless receipt.error? 84 | 85 | yield receipt 86 | end 87 | end 88 | 89 | def unresolved_ids 90 | requested_ids - results.map(&:receipt_id) 91 | end 92 | 93 | private 94 | 95 | attr_accessor :results, :requested_ids 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/push/tickets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Expo 4 | module Push 5 | ## 6 | # A ticket represents a single receipt ticket. 7 | # 8 | # - In case of an #ok? ticket, holds the receipt id in #id 9 | # - In case of an #error? ticket, holds the #message, #explain 10 | # 11 | # Some failed tickets may expose which push token is not or no longer 12 | # valid. This is exposed via #original_push_token. 13 | # 14 | class Ticket 15 | attr_reader :data 16 | 17 | def initialize(data) 18 | self.data = data 19 | end 20 | 21 | def id 22 | data.fetch('id') 23 | end 24 | 25 | def original_push_token 26 | return nil if ok? 27 | 28 | if message.include?('PushToken[') 29 | return /Expo(?:nent)?PushToken\[(?:[^\]]+?)\]/.match(message) { |match| match[0] } 30 | end 31 | 32 | /\A[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}\z/i.match(message) { |match| match[0] } 33 | end 34 | 35 | def message 36 | data.fetch('message') 37 | end 38 | 39 | def explain 40 | Expo::Push::Error.explain((data['details'] || {})['error']) 41 | end 42 | 43 | def ok? 44 | data['status'] == 'ok' 45 | end 46 | 47 | def error? 48 | data['status'] == 'error' 49 | end 50 | 51 | private 52 | 53 | attr_writer :data 54 | end 55 | 56 | ## 57 | # Tickets are paged: each batch when sending the notifications is one 58 | # tickets entry. Each tickets entry has many tickets. 59 | # 60 | # To ease exploration and continuation of the tickets, use the 61 | # folowing methods: 62 | # 63 | # - #batch_ids: slices all the receipts into chunks 64 | # - #each: iterates over each single ticket that is NOT an error 65 | # - #each_error: iterates over each errorered batch and failed ticket 66 | # 67 | # You MUST handle each error, and you MUST first check if its an Error 68 | # or not, because of the way an entire batch call can fail. 69 | # 70 | # @see Ticket 71 | # 72 | class Tickets 73 | def initialize(results) 74 | self.results = results 75 | end 76 | 77 | def ids 78 | [].tap do |ids| 79 | each { |ticket| ids << ticket.id } 80 | end 81 | end 82 | 83 | def batch_ids 84 | ids.each_slice(PUSH_NOTIFICATION_RECEIPT_CHUNK_LIMIT).to_a 85 | end 86 | 87 | def each 88 | results.each do |tickets| 89 | next if tickets.is_a?(Error) 90 | 91 | tickets.each do |ticket| 92 | next unless ticket.ok? 93 | 94 | yield ticket 95 | end 96 | end 97 | end 98 | 99 | def each_error 100 | results.each do |tickets| 101 | if tickets.is_a?(Error) 102 | yield tickets 103 | else 104 | tickets.each do |ticket| 105 | next unless ticket.error? 106 | 107 | yield ticket 108 | end 109 | end 110 | end 111 | end 112 | 113 | private 114 | 115 | attr_accessor :results 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/expo/push/client_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module Expo 6 | module Push 7 | class ClientTest < Minitest::Test 8 | def test_that_it_has_a_version_number 9 | refute_nil ::Expo::Push::Client::VERSION 10 | end 11 | 12 | # rubocop:disable Layout/LineLength 13 | def test_that_it_can_detect_an_expo_push_token 14 | assert Expo::Push.expo_push_token?('ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx]') 15 | assert Expo::Push.expo_push_token?('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]') 16 | assert Expo::Push.expo_push_token?('F5741A13-BCDA-434B-A316-5DC0E6FFA94F') 17 | 18 | # FCM 19 | refute Expo::Push.expo_push_token?('dOKpuo4qbsM:APA91bHkSmF84ROx7Y-2eMGxc0lmpQeN33ZwDMG763dkjd8yjKK-rhPtiR1OoIWNG5ZshlL8oyxsTnQ5XtahyBNS9mJAvfeE6aHzv_mOF_Ve4vL2po4clMIYYV2-Iea_sZVJF7xFLXih4Y0y88JNYULxFfz-XXXXX') 20 | 21 | # APNs 22 | refute Expo::Push.expo_push_token?('5fa729c6e535eb568g18fdabd35785fc60f41c161d9d7cf4b0bbb0d92437fda0') 23 | end 24 | # rubocop:enable Layout/LineLength 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/expo/server/sdk_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | module Expo 6 | module Server 7 | class SdkTest < Minitest::Test 8 | def test_that_it_has_a_version_number 9 | refute_nil ::Expo::Server::SDK::VERSION 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | require 'expo/server/sdk' 5 | 6 | require 'minitest/autorun' 7 | --------------------------------------------------------------------------------