├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Casts └── AsExpoPushToken.php ├── Exceptions └── CouldNotSendNotification.php ├── ExpoChannel.php ├── ExpoError.php ├── ExpoErrorType.php ├── ExpoMessage.php ├── ExpoPushToken.php ├── ExpoServiceProvider.php ├── Gateway ├── ExpoEnvelope.php ├── ExpoGateway.php ├── ExpoGatewayUsingGuzzle.php └── ExpoResponse.php └── Validation └── ExpoPushTokenRule.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `expo` will be documented in this file 4 | 5 | ## 2.0.0 - 2024-03-18 6 | 7 | - Channel revamp 8 | 9 | ## 1.0.0 - 2021-05-29 10 | 11 | - Initial release 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Muhammed Sari muhammed@dive.be 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 |

Social Card of Laravel Expo Channel

2 | 3 | # Expo Notifications Channel 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/laravel-notification-channels/expo.svg?style=flat-square)](https://packagist.org/packages/laravel-notification-channels/expo) 6 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/laravel-notification-channels/expo/run-tests.yml?branch=main)](https://github.com/laravel-notification-channels/expo/actions?query=workflow%3ATests+branch%3Amain) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/laravel-notification-channels/expo.svg?style=flat-square)](https://packagist.org/packages/laravel-notification-channels/expo) 8 | 9 | [Expo](https://docs.expo.dev/push-notifications/overview/) channel for pushing notifications to your React Native apps. 10 | 11 | ## Contents 12 | 13 | - [Installation](#installation) 14 | - [Additional Security](#additional-security-optional) 15 | - [Usage](#usage) 16 | - [Expo Message Request Format](#expo-message-request-format) 17 | - [Testing](#testing) 18 | - [Changelog](#changelog) 19 | - [Contributing](#contributing) 20 | - [Security](#security) 21 | - [Credits](#credits) 22 | - [License](#license) 23 | 24 | ## Installation 25 | 26 | You can install the package via Composer: 27 | 28 | ```bash 29 | composer require laravel-notification-channels/expo 30 | ``` 31 | 32 | ## Additional Security (optional) 33 | 34 | You can require any push notifications to be sent with an additional [Access Token](https://docs.expo.dev/push-notifications/sending-notifications/#additional-security) before Expo delivers them to your users. 35 | 36 | If you want to make use of this additional security layer, add the following to your `config/services.php` file: 37 | 38 | ```php 39 | 'expo' => [ 40 | 'access_token' => env('EXPO_ACCESS_TOKEN'), 41 | ], 42 | ``` 43 | 44 | ## Usage 45 | 46 | You can now use the `expo` channel in the `via()` method of your `Notification`s. 47 | 48 | ### Notification / `ExpoMessage` 49 | 50 | First things first, you need to have a [Notification](https://laravel.com/docs/9.x/notifications) that needs to be delivered to someone. Check out the [Laravel documentation](https://laravel.com/docs/9.x/notifications#generating-notifications) for more information on generating notifications. 51 | 52 | ```php 53 | use NotificationChannels\Expo\ExpoMessage; 54 | 55 | class SuspiciousActivityDetected extends Notification 56 | { 57 | public function toExpo($notifiable): ExpoMessage 58 | { 59 | return ExpoMessage::create('Suspicious Activity') 60 | ->body('Someone tried logging in to your account!') 61 | ->data($notifiable->only('email', 'id')) 62 | ->expiresAt(now()->addHour()) 63 | ->priority('high') 64 | ->playSound(); 65 | } 66 | 67 | public function via($notifiable): array 68 | { 69 | return ['expo']; 70 | } 71 | } 72 | ``` 73 | 74 | > [!NOTE] 75 | > Detailed explanation regarding the Expo Message Request Format can be found [here](#expo-message-request-format). 76 | 77 | You can also apply conditionals to `ExpoMessage` without breaking the method chain: 78 | 79 | ```php 80 | use NotificationChannels\Expo\ExpoMessage; 81 | 82 | public function toExpo($notifiable): ExpoMessage 83 | { 84 | return ExpoMessage::create('Suspicious Activity') 85 | ->body('Someone tried logging in to your account!') 86 | ->when($notifiable->wantsSound(), fn ($msg) => $msg->playSound()) 87 | ->unless($notifiable->isVip(), fn ($msg) => $msg->normal(), fn ($msg) => $msg->high()); 88 | } 89 | ``` 90 | 91 | ### Notifiable / `ExpoPushToken` 92 | 93 | Next, you will have to set a `routeNotificationForExpo()` method in your `Notifiable` model. 94 | 95 | #### Unicasting (single device) 96 | 97 | The method **must** return either an instance of `ExpoPushToken` or `null`. An example: 98 | 99 | ```php 100 | use NotificationChannels\Expo\ExpoPushToken; 101 | 102 | class User extends Authenticatable 103 | { 104 | use Notifiable; 105 | 106 | /** 107 | * Get the attributes that should be cast. 108 | * 109 | * @return array 110 | */ 111 | protected function casts(): array 112 | { 113 | return [ 114 | 'expo_token' => ExpoPushToken::class 115 | ]; 116 | } 117 | 118 | public function routeNotificationForExpo(): ?ExpoPushToken 119 | { 120 | return $this->expo_token; 121 | } 122 | } 123 | ``` 124 | 125 | > [!IMPORTANT] 126 | > No notifications will be sent in case of `null`. 127 | 128 | > [!NOTE] 129 | > More info regarding the model cast can be found [here](#model-casting). 130 | 131 | #### Multicasting (multiple devices) 132 | 133 | The method **must** return an `array` or `Collection`, 134 | the specific implementation depends on your use case. An example: 135 | 136 | ```php 137 | use Illuminate\Database\Eloquent\Collection; 138 | 139 | class User extends Authenticatable 140 | { 141 | use Notifiable; 142 | 143 | /** 144 | * @return Collection 145 | */ 146 | public function routeNotificationForExpo(): Collection 147 | { 148 | return $this->devices->pluck('expo_token'); 149 | } 150 | } 151 | ``` 152 | 153 | > [!IMPORTANT] 154 | > No notifications will be sent in case of an empty `Collection`. 155 | 156 | ### Sending 157 | 158 | Once everything is in place, you can simply send a notification by calling: 159 | 160 | ```php 161 | $user->notify(new SuspiciousActivityDetected()); 162 | ``` 163 | 164 | ### Validation 165 | 166 | You ought to have an HTTP endpoint that associates a given `ExpoPushToken` with an authenticated `User` so that you can deliver push notifications. For this reason, we're also providing a custom validation `ExpoPushTokenRule` class which you can use to protect your endpoints. An example: 167 | 168 | ```php 169 | use NotificationChannels\Expo\ExpoPushToken; 170 | 171 | class StoreDeviceRequest extends FormRequest 172 | { 173 | public function rules(): array 174 | { 175 | return [ 176 | 'device_id' => ['required', 'string', 'min:2', 'max:255'], 177 | 'token' => ['required', ExpoPushToken::rule()], 178 | ]; 179 | } 180 | } 181 | ``` 182 | 183 | ### Model casting 184 | 185 | The `ExpoChannel` expects you to return an instance of `ExpoPushToken` from your `Notifiable`s. You can easily achieve this by applying the `ExpoPushToken` as a custom model cast. An example: 186 | 187 | ```php 188 | use NotificationChannels\Expo\ExpoPushToken; 189 | 190 | class User extends Authenticatable 191 | { 192 | use Notifiable; 193 | 194 | /** 195 | * Get the attributes that should be cast. 196 | * 197 | * @return array 198 | */ 199 | protected function casts(): array 200 | { 201 | return [ 202 | 'expo_token' => ExpoPushToken::class 203 | ]; 204 | } 205 | } 206 | ``` 207 | 208 | This custom value object guarantees the integrity of the push token. You should make sure that [only valid tokens](#validation) are saved. 209 | 210 | ### Handling failed deliveries 211 | 212 | Unfortunately, Laravel does not provide an [OOB solution](https://github.com/laravel-notification-channels/channels/issues/16) for handling failed deliveries. However, there is a `NotificationFailed` event which Laravel does provide so you can hook into failed delivery attempts. This is particularly useful when an old token is no longer valid and the service starts responding with `DeviceNotRegistered` errors. 213 | 214 | You can register an event listener that listens to this event and handles the appropriate errors. An example: 215 | 216 | ```php 217 | use Illuminate\Notifications\Events\NotificationFailed; 218 | 219 | class HandleFailedExpoNotifications 220 | { 221 | public function handle(NotificationFailed $event) 222 | { 223 | if ($event->channel !== 'expo') return; 224 | 225 | /** @var ExpoError $error */ 226 | $error = $event->data; 227 | 228 | // Remove old token 229 | if ($error->type->isDeviceNotRegistered()) { 230 | $event->notifiable->update(['expo_token' => null]); 231 | } else { 232 | // do something else like logging... 233 | } 234 | } 235 | } 236 | ``` 237 | 238 | The `NotificationFailed::$data` property will contain an instance of `ExpoError` which has the following properties: 239 | 240 | ```php 241 | namespace NotificationChannels\Expo; 242 | 243 | final readonly class ExpoError 244 | { 245 | private function __construct( 246 | public ExpoErrorType $type, 247 | public ExpoPushToken $token, 248 | public string $message, 249 | ) {} 250 | } 251 | ``` 252 | 253 | ## Expo Message Request Format 254 | 255 | The `ExpoMessage` class contains the following methods for defining the message payload. All of these methods correspond to the available payload defined in the [Expo Push documentation](https://docs.expo.dev/push-notifications/sending-notifications/#message-request-format). 256 | 257 | - [Badge (iOS)](#badge-ios) 258 | - [Body](#body) 259 | - [Category ID](#category-id) 260 | - [Channel ID (Android)](#channel-id-android) 261 | - [JSON data](#json-data) 262 | - [Expiration](#expiration) 263 | - [Mutable content (iOS)](#mutable-content-ios) 264 | - [Notification sound (iOS)](#notification-sound-ios) 265 | - [Priority](#priority) 266 | - [Subtitle (iOS)](#subtitle-ios) 267 | - [Title](#title) 268 | - [TTL (Time to live)](#ttl-time-to-live) 269 | 270 | ### Badge (iOS) 271 | 272 | Sets the number to display in the badge on the app icon. 273 | 274 | ```php 275 | badge(int $value) 276 | ``` 277 | 278 | > [!NOTE] 279 | > The value must be greater than or equal to 0. 280 | 281 | ### Body 282 | 283 | Sets the message body to display in the notification. 284 | 285 | ```php 286 | body(string $value) 287 | text(string $value) 288 | ``` 289 | 290 | > [!NOTE] 291 | > The value must not be empty. 292 | 293 | ### Category ID 294 | 295 | Sets the ID of the notification category that this notification is associated with. 296 | 297 | ```php 298 | categoryId(string $value) 299 | ``` 300 | 301 | > [!NOTE] 302 | > The value must not be empty. 303 | 304 | ### Channel ID (Android) 305 | 306 | Sets the ID of the Notification Channel through which to display this notification. 307 | 308 | ```php 309 | channelId(string $value) 310 | ``` 311 | 312 | > [!NOTE] 313 | > The value must not be empty. 314 | 315 | ### JSON data 316 | 317 | Sets the JSON data for the message. 318 | 319 | ```php 320 | data(Arrayable|Jsonable|JsonSerializable|array $value) 321 | ``` 322 | 323 | > [!WARNING] 324 | > We're compressing JSON payloads that exceed 1 KiB using Gzip (if [`ext-zlib`](https://www.php.net/manual/en/book.zlib.php) is available). While you could technically send more than 4 KiB of data, this is not recommended. 325 | 326 | ### Expiration 327 | 328 | Sets the expiration time of the message. Same effect as TTL. 329 | 330 | ```php 331 | expiresAt(DateTimeInterface|int $value) 332 | ``` 333 | 334 | > [!WARNING] 335 | > `TTL` takes precedence if both are set. 336 | 337 | > [!NOTE] 338 | > The value must be in the future. 339 | 340 | ### Mutable content (iOS) 341 | 342 | Sets whether the notification can be intercepted by the client app. 343 | 344 | ```php 345 | mutableContent(bool $value = true) 346 | ``` 347 | 348 | ### Notification sound (iOS) 349 | 350 | Play the default notification sound when the recipient receives the notification. 351 | 352 | ```php 353 | playSound() 354 | ``` 355 | 356 | > [!WARNING] 357 | > Custom sounds are not supported. 358 | 359 | ### Priority 360 | 361 | Sets the delivery priority of the message. 362 | 363 | ```php 364 | priority(string $value) 365 | default() 366 | normal() 367 | high() 368 | ``` 369 | 370 | > [!NOTE] 371 | > The value must be `default`, `normal` or `high`. 372 | 373 | ### Subtitle (iOS) 374 | 375 | Sets the subtitle to display in the notification below the title. 376 | 377 | ```php 378 | subtitle(string $value) 379 | ``` 380 | 381 | > [!NOTE] 382 | > The value must not be empty. 383 | 384 | ### Title 385 | 386 | Set the title to display in the notification. 387 | 388 | ```php 389 | title(string $value) 390 | ``` 391 | 392 | > [!NOTE] 393 | > The value must not be empty. 394 | 395 | ### TTL (Time to live) 396 | 397 | Set the number of seconds for which the message may be kept around for redelivery. 398 | 399 | ```php 400 | ttl(int $value) 401 | expiresIn(int $value) 402 | ``` 403 | 404 | > [!WARNING] 405 | > Takes precedence over `expiration` if both are set. 406 | 407 | > [!NOTE] 408 | > The value must be greater than 0. 409 | 410 | ## Testing 411 | 412 | ```bash 413 | composer test 414 | ``` 415 | 416 | ## Changelog 417 | 418 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 419 | 420 | ## Contributing 421 | 422 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 423 | 424 | ## Security 425 | 426 | If you discover any security related issues, please email muhammed@dive.be instead of using the issue tracker. 427 | 428 | ## Credits 429 | 430 | - [Muhammed Sari](https://github.com/mabdullahsari) 431 | - [All Contributors](../../contributors) 432 | 433 | ## License 434 | 435 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 436 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-notification-channels/expo", 3 | "description": "Expo Notifications Channel for Laravel", 4 | "homepage": "https://github.com/laravel-notification-channels/expo", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Muhammed Sari", 9 | "email": "muhammed@dive.be", 10 | "homepage": "https://dive.be", 11 | "role": "Developer" 12 | } 13 | ], 14 | "require": { 15 | "php": "~8.3", 16 | "ext-json": "*", 17 | "guzzlehttp/guzzle": "^7.1", 18 | "illuminate/contracts": "^11.0|^12.0", 19 | "illuminate/notifications": "^11.0|^12.0", 20 | "illuminate/support": "^11.0|^12.0" 21 | }, 22 | "require-dev": { 23 | "larastan/larastan": "^3.0", 24 | "laravel/pint": "^1.0", 25 | "orchestra/testbench": "^9.11", 26 | "phpunit/phpunit": "^11.0" 27 | }, 28 | "suggest": { 29 | "ext-zlib": "Required for compressing payloads exceeding 1 KiB in size." 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "NotificationChannels\\Expo\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "larastan": "vendor/bin/phpstan analyse --memory-limit=2G", 43 | "format": "vendor/bin/pint", 44 | "test": "vendor/bin/phpunit", 45 | "verify": ["@format", "@larastan", "@test"] 46 | }, 47 | "config": { 48 | "sort-packages": true 49 | }, 50 | "extra": { 51 | "laravel": { 52 | "providers": [ 53 | "NotificationChannels\\Expo\\ExpoServiceProvider" 54 | ] 55 | } 56 | }, 57 | "minimum-stability": "dev", 58 | "prefer-stable": true 59 | } 60 | -------------------------------------------------------------------------------- /src/Casts/AsExpoPushToken.php: -------------------------------------------------------------------------------- 1 | ExpoPushToken::make($value), 20 | is_null($value), $value instanceof ExpoPushToken => $value, 21 | default => throw new InvalidArgumentException('The given value cannot be cast to an instance of ExpoPushToken.'), 22 | }; 23 | } 24 | 25 | /** 26 | * Transform the attribute to its underlying model values from an ExpoPushToken. 27 | */ 28 | public function set($model, string $key, $value, array $attributes): ?string 29 | { 30 | $value = $this->get($model, $key, $value, $attributes); 31 | 32 | return $value instanceof ExpoPushToken ? $value->asString() : $value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exceptions/CouldNotSendNotification.php: -------------------------------------------------------------------------------- 1 | getTokens($notifiable, $notification); 39 | 40 | if (! count($tokens)) { 41 | return; 42 | } 43 | 44 | $message = $this->getMessage($notifiable, $notification); 45 | 46 | $response = $this->gateway->sendPushNotifications( 47 | ExpoEnvelope::make($tokens, $message) 48 | ); 49 | 50 | if ($response->isFailure()) { 51 | $this->dispatchFailedEvents($notifiable, $notification, $response->errors()); 52 | } elseif ($response->isFatal()) { 53 | throw CouldNotSendNotification::becauseTheServiceRespondedWithAnError($response->message()); 54 | } 55 | } 56 | 57 | /** 58 | * Dispatch failed events for notifications that weren't delivered. 59 | */ 60 | private function dispatchFailedEvents(object $notifiable, Notification $notification, array $errors): void 61 | { 62 | foreach ($errors as $error) { 63 | $this->events->dispatch(new NotificationFailed($notifiable, $notification, self::NAME, $error)); 64 | } 65 | } 66 | 67 | /** 68 | * Get the message that should be delivered. 69 | * 70 | * @throws CouldNotSendNotification 71 | */ 72 | private function getMessage(object $notifiable, Notification $notification): ExpoMessage 73 | { 74 | if (! method_exists($notification, 'toExpo')) { 75 | throw CouldNotSendNotification::becauseTheMessageIsMissing(); 76 | } 77 | 78 | return $notification->toExpo($notifiable); 79 | } 80 | 81 | /** 82 | * Get the recipients that the message should be delivered to. 83 | * 84 | * @return array 85 | * 86 | * @throws CouldNotSendNotification 87 | */ 88 | private function getTokens(object $notifiable, Notification $notification): array 89 | { 90 | if (! method_exists($notifiable, 'routeNotificationFor')) { 91 | throw CouldNotSendNotification::becauseNotifiableIsInvalid(); 92 | } 93 | 94 | $tokens = $notifiable->routeNotificationFor(self::NAME, $notification); 95 | 96 | if ($tokens instanceof Arrayable) { 97 | $tokens = $tokens->toArray(); 98 | } 99 | 100 | return Arr::wrap($tokens); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/ExpoError.php: -------------------------------------------------------------------------------- 1 | body = $body; 122 | $this->title = $title; 123 | } 124 | 125 | /** 126 | * Start creating a message with a given title and body. 127 | */ 128 | public static function create(string $title = '', string $body = ''): self 129 | { 130 | return new self($title, $body); 131 | } 132 | 133 | /** 134 | * Set the number to display in the badge on the app icon. 135 | * 136 | * @throws InvalidArgumentException() 137 | * 138 | * @see ExpoMessage::$badge 139 | */ 140 | public function badge(int $value): self 141 | { 142 | if ($value < 0) { 143 | throw new InvalidArgumentException('The badge must be greater than or equal to 0.'); 144 | } 145 | 146 | $this->badge = $value; 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * Set the message body to display in the notification. 153 | * 154 | * @throws InvalidArgumentException() 155 | * 156 | * @see ExpoMessage::$body 157 | */ 158 | public function body(string $value): self 159 | { 160 | if (empty($value)) { 161 | throw new InvalidArgumentException('The body must not be empty.'); 162 | } 163 | 164 | $this->body = $value; 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * Set the ID of the notification category that this notification is associated with. 171 | * 172 | * @throws InvalidArgumentException() 173 | * 174 | * @see ExpoMessage::$categoryId 175 | */ 176 | public function categoryId(string $value): self 177 | { 178 | if (empty($value)) { 179 | throw new InvalidArgumentException('The categoryId must not be empty.'); 180 | } 181 | 182 | $this->categoryId = $value; 183 | 184 | return $this; 185 | } 186 | 187 | /** 188 | * Set the ID of the Notification Channel through which to display this notification. 189 | * 190 | * @throws InvalidArgumentException() 191 | * 192 | * @see ExpoMessage::$channelId 193 | */ 194 | public function channelId(string $value): self 195 | { 196 | if (empty($value)) { 197 | throw new InvalidArgumentException('The channelId must not be empty.'); 198 | } 199 | 200 | $this->channelId = $value; 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Set the JSON data for the message. 207 | * 208 | * @throws \JsonException 209 | * 210 | * @see ExpoMessage::$data 211 | */ 212 | public function data(Arrayable|Jsonable|JsonSerializable|array $value): self 213 | { 214 | if ($value instanceof Arrayable) { 215 | $value = $value->toArray(); 216 | } 217 | 218 | if ($value instanceof Jsonable) { 219 | $value = $value->toJson(JSON_THROW_ON_ERROR); 220 | } else { 221 | $value = json_encode($value, JSON_THROW_ON_ERROR); 222 | } 223 | 224 | $this->data = $value; 225 | 226 | return $this; 227 | } 228 | 229 | /** 230 | * Set the delivery priority of the message to 'default'. 231 | * 232 | * @see ExpoMessage::$priority 233 | */ 234 | public function default(): self 235 | { 236 | $this->priority = __FUNCTION__; 237 | 238 | return $this; 239 | } 240 | 241 | /** 242 | * Set the expiration time of the message. 243 | * 244 | * @throws InvalidArgumentException() 245 | * 246 | * @see ExpoMessage::$expiration 247 | */ 248 | public function expiresAt(DateTimeInterface|int $value): self 249 | { 250 | if ($value instanceof DateTimeInterface) { 251 | $value = $value->getTimestamp(); 252 | } 253 | 254 | if ($value - time() <= 0) { 255 | throw new InvalidArgumentException('The expiration time must be in the future.'); 256 | } 257 | 258 | $this->expiration = $value; 259 | 260 | return $this; 261 | } 262 | 263 | /** 264 | * @see ExpoMessage::ttl() 265 | */ 266 | public function expiresIn(int $value): self 267 | { 268 | return $this->ttl($value); 269 | } 270 | 271 | /** 272 | * Set the delivery priority of the message to 'high'. 273 | * 274 | * @see ExpoMessage::$priority 275 | */ 276 | public function high(): self 277 | { 278 | $this->priority = __FUNCTION__; 279 | 280 | return $this; 281 | } 282 | 283 | /** 284 | * Set whether the notification can be intercepted by the client app. 285 | * 286 | * @see ExpoMessage::$mutableContent 287 | */ 288 | public function mutableContent(bool $value = true): self 289 | { 290 | $this->mutableContent = $value; 291 | 292 | return $this; 293 | } 294 | 295 | /** 296 | * Set the delivery priority of the message to 'normal'. 297 | * 298 | * @see ExpoMessage::$priority 299 | */ 300 | public function normal(): self 301 | { 302 | $this->priority = __FUNCTION__; 303 | 304 | return $this; 305 | } 306 | 307 | /** 308 | * Play a sound when the recipient receives the notification. 309 | * 310 | * @see ExpoMessage::$sound 311 | */ 312 | public function playSound(): self 313 | { 314 | $this->sound = 'default'; 315 | 316 | return $this; 317 | } 318 | 319 | /** 320 | * Set the delivery priority of the message, either 'default', 'normal' or 'high. 321 | * 322 | * @throws InvalidArgumentException() 323 | * 324 | * @see ExpoMessage::$priority 325 | */ 326 | public function priority(string $value): self 327 | { 328 | $value = strtolower($value); 329 | 330 | if (! in_array($value, self::PRIORITIES)) { 331 | throw new InvalidArgumentException('The priority must be default, normal or high.'); 332 | } 333 | 334 | $this->priority = $value; 335 | 336 | return $this; 337 | } 338 | 339 | /** 340 | * Set the subtitle to display in the notification below the title. 341 | * 342 | * @throws InvalidArgumentException() 343 | * 344 | * @see ExpoMessage::$subtitle 345 | */ 346 | public function subtitle(string $value): self 347 | { 348 | if (empty($value)) { 349 | throw new InvalidArgumentException('The subtitle must not be empty.'); 350 | } 351 | 352 | $this->subtitle = $value; 353 | 354 | return $this; 355 | } 356 | 357 | /** 358 | * @see ExpoMessage::body() 359 | */ 360 | public function text(string $value): self 361 | { 362 | return $this->body($value); 363 | } 364 | 365 | /** 366 | * Set the title to display in the notification. 367 | * 368 | * @throws InvalidArgumentException() 369 | * 370 | * @see ExpoMessage::$title 371 | */ 372 | public function title(string $value): self 373 | { 374 | if (empty($value)) { 375 | throw new InvalidArgumentException('The title must not be empty.'); 376 | } 377 | 378 | $this->title = $value; 379 | 380 | return $this; 381 | } 382 | 383 | /** 384 | * Set the number of seconds for which the message may be kept around for redelivery. 385 | * 386 | * @throws InvalidArgumentException() 387 | * 388 | * @see ExpoMessage::$ttl 389 | */ 390 | public function ttl(int $value): self 391 | { 392 | if ($value <= 0) { 393 | throw new InvalidArgumentException('The TTL must be greater than 0.'); 394 | } 395 | 396 | $this->ttl = $value; 397 | 398 | return $this; 399 | } 400 | 401 | /** 402 | * Convert the ExpoMessage instance to its JSON representation. 403 | */ 404 | public function jsonSerialize(): array 405 | { 406 | return $this->toArray(); 407 | } 408 | 409 | /** 410 | * Get the ExpoMessage instance as an array. 411 | */ 412 | public function toArray(): array 413 | { 414 | return array_filter(get_object_vars($this), filled(...)); 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /src/ExpoPushToken.php: -------------------------------------------------------------------------------- 1 | value = $value; 46 | } 47 | 48 | /** 49 | * Get the FQCN of the caster to use when casting from / to an ExpoPushToken. 50 | */ 51 | public static function castUsing(array $arguments): string 52 | { 53 | return AsExpoPushToken::class; 54 | } 55 | 56 | /** 57 | * @see __construct() 58 | */ 59 | public static function make(string $token): self 60 | { 61 | return new self($token); 62 | } 63 | 64 | /** 65 | * Get the rule to validate an ExpoPushToken. 66 | */ 67 | public static function rule(): ExpoPushTokenRule 68 | { 69 | return ExpoPushTokenRule::make(); 70 | } 71 | 72 | /** 73 | * @see __toString() 74 | */ 75 | public function asString(): string 76 | { 77 | return $this->value; 78 | } 79 | 80 | /** 81 | * Determine whether a given token is equal. 82 | */ 83 | public function equals(self|string $other): bool 84 | { 85 | if ($other instanceof self) { 86 | $other = $other->asString(); 87 | } 88 | 89 | return $other === $this->asString(); 90 | } 91 | 92 | /** 93 | * Convert the ExpoPushToken instance to its JSON representation. 94 | */ 95 | public function jsonSerialize(): string 96 | { 97 | return $this->asString(); 98 | } 99 | 100 | /** 101 | * Get the string representation of the push token. 102 | */ 103 | public function __toString(): string 104 | { 105 | return $this->asString(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/ExpoServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(ExpoGateway::class, $this->createExpoGateway(...)); 23 | $this->app->singleton(ExpoChannel::class); 24 | 25 | $this->callAfterResolving(ChannelManager::class, $this->extendManager(...)); 26 | } 27 | 28 | /** 29 | * Create a new ExpoGateway instance. 30 | */ 31 | private function createExpoGateway(Application $app): ExpoGatewayUsingGuzzle 32 | { 33 | /** @var Repository $config */ 34 | $config = $app->make(Repository::class); 35 | 36 | $accessToken = $config->get('services.expo.access_token'); 37 | 38 | if (! is_null($accessToken) && ! is_string($accessToken)) { 39 | throw new InvalidArgumentException('The provided access token is not a valid Expo Access Token.'); 40 | } 41 | 42 | return new ExpoGatewayUsingGuzzle($accessToken); 43 | } 44 | 45 | /** 46 | * Extend the ChannelManager with ExpoChannel. 47 | */ 48 | private function extendManager(ChannelManager $cm): void 49 | { 50 | $cm->extend(ExpoChannel::NAME, static fn (Application $app) => $app->make(ExpoChannel::class)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Gateway/ExpoEnvelope.php: -------------------------------------------------------------------------------- 1 | $recipients 20 | */ 21 | private function __construct(public array $recipients, public ExpoMessage $message) 22 | { 23 | if (! count($recipients)) { 24 | throw new InvalidArgumentException('There must be at least 1 recipient.'); 25 | } 26 | } 27 | 28 | /** 29 | * @see __construct() 30 | */ 31 | public static function make(array $recipients, ExpoMessage $message): self 32 | { 33 | return new self($recipients, $message); 34 | } 35 | 36 | /** 37 | * Get the ExpoEnvelope instance as an array. 38 | */ 39 | public function toArray(): array 40 | { 41 | $envelope = $this->message->toArray(); 42 | $envelope['to'] = array_map(static fn (ExpoPushToken $token) => $token->asString(), $this->recipients); 43 | 44 | return $envelope; 45 | } 46 | 47 | /** 48 | * Convert the ExpoEnvelope instance to its JSON representation. 49 | * 50 | * @param int $options 51 | */ 52 | public function toJson($options = 0): string 53 | { 54 | return json_encode($this->toArray(), $options) ?: ''; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Gateway/ExpoGateway.php: -------------------------------------------------------------------------------- 1 | http = new Client([RequestOptions::HEADERS => $this->getDefaultHeaders($accessToken)]); 50 | } 51 | 52 | /** 53 | * Send the notifications to Expo's Push Service. 54 | */ 55 | public function sendPushNotifications(ExpoEnvelope $envelope): ExpoResponse 56 | { 57 | [$headers, $body] = $this->compressUsingGzip($envelope->toJson()); 58 | 59 | $response = $this->http->post(self::BASE_URL, [ 60 | RequestOptions::BODY => $body, 61 | RequestOptions::HEADERS => $headers, 62 | RequestOptions::HTTP_ERRORS => false, 63 | ]); 64 | 65 | if ($response->getStatusCode() !== self::HTTP_OK) { 66 | return ExpoResponse::fatal((string) $response->getBody()); 67 | } 68 | 69 | $tickets = $this->getPushTickets($response); 70 | $errors = $this->getPotentialErrors($envelope->recipients, $tickets); 71 | 72 | return count($errors) ? ExpoResponse::failed($errors) : ExpoResponse::ok(); 73 | } 74 | 75 | /** 76 | * Compress the given payload if the size is greater than the threshold (1 KiB). 77 | */ 78 | private function compressUsingGzip(string $payload): array 79 | { 80 | if (! extension_loaded('zlib')) { 81 | return [[], $payload]; 82 | } 83 | 84 | if (mb_strlen($payload) / self::KIBIBYTE <= self::THRESHOLD) { 85 | return [[], $payload]; 86 | } 87 | 88 | $encoded = gzencode($payload, 6); 89 | 90 | if ($encoded === false) { 91 | return [[], $payload]; 92 | } 93 | 94 | return [['Content-Encoding' => 'gzip'], $encoded]; 95 | } 96 | 97 | /** 98 | * Get the default headers to be used by the HTTP client. 99 | */ 100 | private function getDefaultHeaders(#[SensitiveParameter] ?string $accessToken): array 101 | { 102 | $headers = [ 103 | 'Accept' => 'application/json', 104 | 'Accept-Encoding' => 'gzip, deflate', 105 | 'Content-Type' => 'application/json', 106 | 'Host' => 'exp.host', 107 | ]; 108 | 109 | if (is_string($accessToken)) { 110 | $headers['Authorization'] = "Bearer {$accessToken}"; 111 | } 112 | 113 | return $headers; 114 | } 115 | 116 | /** 117 | * Get an array of potential errors responded by the service. 118 | * 119 | * @param $tokens array 120 | * @return array 121 | */ 122 | private function getPotentialErrors(array $tokens, array $tickets): array 123 | { 124 | $errors = []; 125 | 126 | foreach ($tickets as $idx => $ticket) { 127 | if (Arr::get($ticket, 'status') === 'error') { 128 | $errors[] = $this->makeError($tokens[$idx], $ticket); 129 | } 130 | } 131 | 132 | return $errors; 133 | } 134 | 135 | /** 136 | * Get the array of push tickets responded by the service. 137 | */ 138 | private function getPushTickets(ResponseInterface $response): array 139 | { 140 | /** @var array $body */ 141 | $body = json_decode((string) $response->getBody(), true); 142 | 143 | return Arr::get($body, 'data', []); 144 | } 145 | 146 | /** 147 | * Create and return an ExpoError object representing a failed delivery. 148 | */ 149 | private function makeError(ExpoPushToken $token, array $ticket): ExpoError 150 | { 151 | /** @var string $type */ 152 | $type = Arr::get($ticket, 'details.error'); 153 | $type = ExpoErrorType::from($type); 154 | 155 | /** @var string $message */ 156 | $message = Arr::get($ticket, 'message'); 157 | 158 | return ExpoError::make($type, $token, $message); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Gateway/ExpoResponse.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | public static function failed(array $errors): self 32 | { 33 | return new self(self::FAILED, $errors); 34 | } 35 | 36 | /** 37 | * Create a "fatal" ExpoResponse instance. 38 | */ 39 | public static function fatal(string $message): self 40 | { 41 | return new self(self::FATAL, $message); 42 | } 43 | 44 | /** 45 | * Create an "ok" ExpoResponse instance. 46 | */ 47 | public static function ok(): self 48 | { 49 | return new self(self::OK); 50 | } 51 | 52 | public function errors(): array 53 | { 54 | return is_array($this->context) ? $this->context : []; 55 | } 56 | 57 | public function isFatal(): bool 58 | { 59 | return $this->type === self::FATAL; 60 | } 61 | 62 | public function isFailure(): bool 63 | { 64 | return $this->type === self::FAILED; 65 | } 66 | 67 | public function isOk(): bool 68 | { 69 | return $this->type === self::OK; 70 | } 71 | 72 | public function message(): string 73 | { 74 | return is_string($this->context) ? $this->context : ''; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Validation/ExpoPushTokenRule.php: -------------------------------------------------------------------------------- 1 | translate(); 29 | 30 | return; 31 | } 32 | 33 | try { 34 | ExpoPushToken::make($value); 35 | } catch (InvalidArgumentException) { 36 | $fail('validation.regex')->translate(); 37 | } 38 | } 39 | } 40 | --------------------------------------------------------------------------------