├── 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 |

2 |
3 | # Expo Notifications Channel
4 |
5 | [](https://packagist.org/packages/laravel-notification-channels/expo)
6 | [](https://github.com/laravel-notification-channels/expo/actions?query=workflow%3ATests+branch%3Amain)
7 | [](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 |
--------------------------------------------------------------------------------