diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 7cf9a7a656fe3..b4d8b71d6592d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -116,6 +116,7 @@ use Symfony\Component\Notifier\Bridge\Clickatell\ClickatellTransportFactory; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory; use Symfony\Component\Notifier\Bridge\Esendex\EsendexTransportFactory; +use Symfony\Component\Notifier\Bridge\Expo\ExpoTransportFactory; use Symfony\Component\Notifier\Bridge\FakeChat\FakeChatTransportFactory; use Symfony\Component\Notifier\Bridge\FakeSms\FakeSmsTransportFactory; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; @@ -2452,6 +2453,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ ClickatellTransportFactory::class => 'notifier.transport_factory.clickatell', DiscordTransportFactory::class => 'notifier.transport_factory.discord', EsendexTransportFactory::class => 'notifier.transport_factory.esendex', + ExpoTransportFactory::class => 'notifier.transport_factory.expo', FakeChatTransportFactory::class => 'notifier.transport_factory.fakechat', FakeSmsTransportFactory::class => 'notifier.transport_factory.fakesms', FirebaseTransportFactory::class => 'notifier.transport_factory.firebase', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index 5aafabfbff21f..d77f395e030e3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -16,6 +16,7 @@ use Symfony\Component\Notifier\Bridge\Clickatell\ClickatellTransportFactory; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory; use Symfony\Component\Notifier\Bridge\Esendex\EsendexTransportFactory; +use Symfony\Component\Notifier\Bridge\Expo\ExpoTransportFactory; use Symfony\Component\Notifier\Bridge\FakeChat\FakeChatTransportFactory; use Symfony\Component\Notifier\Bridge\FakeSms\FakeSmsTransportFactory; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; @@ -235,5 +236,9 @@ ->set('notifier.transport_factory.onesignal', OneSignalTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + + ->set('notifier.transport_factory.expo', ExpoTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') ; }; diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Expo/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/.gitignore b/src/Symfony/Component/Notifier/Bridge/Expo/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Expo/CHANGELOG.md new file mode 100644 index 0000000000000..3a08c7ededfcd --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.4 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/ExpoOptions.php b/src/Symfony/Component/Notifier/Bridge/Expo/ExpoOptions.php new file mode 100644 index 0000000000000..e3d453c6d70bb --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/ExpoOptions.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Expo; + +use Symfony\Component\Notifier\Message\MessageOptionsInterface; + +/** + * @author Imad ZAIRIG + * + * @see https://docs.expo.dev/push-notifications/sending-notifications/ + */ +final class ExpoOptions implements MessageOptionsInterface +{ + private $to; + + /** + * @see https://docs.expo.dev/push-notifications/sending-notifications/#message-request-format + */ + protected $options; + + private $data; + + public function __construct(string $to, array $options = [], array $data = []) + { + $this->to = $to; + $this->options = $options; + $this->data = $data; + } + + public function toArray(): array + { + return array_merge( + $this->options, + [ + 'to' => $this->to, + 'data' => $this->data, + ] + ); + } + + public function getRecipientId(): ?string + { + return $this->to; + } + + /** + * @return $this + */ + public function title(string $title): self + { + $this->options['title'] = $title; + + return $this; + } + + /** + * @return $this + */ + public function subtitle(string $subtitle): self + { + $this->options['subtitle'] = $subtitle; + + return $this; + } + + /** + * @return $this + */ + public function priority(string $priority): self + { + $this->options['priority'] = $priority; + + return $this; + } + + /** + * @return $this + */ + public function sound(string $sound): self + { + $this->options['sound'] = $sound; + + return $this; + } + + /** + * @return $this + */ + public function badge(int $badge): self + { + $this->options['badge'] = $badge; + + return $this; + } + + /** + * @return $this + */ + public function channelId(string $channelId): self + { + $this->options['channelId'] = $channelId; + + return $this; + } + + /** + * @return $this + */ + public function categoryId(string $categoryId): self + { + $this->options['categoryId'] = $categoryId; + + return $this; + } + + /** + * @return $this + */ + public function mutableContent(bool $mutableContent): self + { + $this->options['mutableContent'] = $mutableContent; + + return $this; + } + + /** + * @return $this + */ + public function body(string $body): self + { + $this->options['body'] = $body; + + return $this; + } + + /** + * @return $this + */ + public function ttl(int $ttl): self + { + $this->options['ttl'] = $ttl; + + return $this; + } + + /** + * @return $this + */ + public function expiration(int $expiration): self + { + $this->options['expiration'] = $expiration; + + return $this; + } + + /** + * @return $this + */ + public function data(array $data): self + { + $this->data = $data; + + return $this; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/ExpoTransport.php b/src/Symfony/Component/Notifier/Bridge/Expo/ExpoTransport.php new file mode 100644 index 0000000000000..455b3888f74a5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/ExpoTransport.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Expo; + +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\PushMessage; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Imad ZAIRIG + */ +final class ExpoTransport extends AbstractTransport +{ + protected const HOST = 'exp.host/--/api/v2/push/send'; + + /** @var string|null */ + private $token; + + public function __construct(string $token = null, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null) + { + $this->token = $token; + $this->client = $client; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('expo://%s', $this->getEndpoint()); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof PushMessage; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof PushMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, PushMessage::class, $message); + } + + $endpoint = sprintf('https://%s', $this->getEndpoint()); + $options = ($opts = $message->getOptions()) ? $opts->toArray() : []; + if (!isset($options['to'])) { + $options['to'] = $message->getRecipientId(); + } + if (null === $options['to']) { + throw new InvalidArgumentException(sprintf('The "%s" transport required the "to" option to be set.', __CLASS__)); + } + + $options['title'] = $message->getSubject(); + $options['body'] = $message->getContent(); + $options['data'] = $options['data'] ?? []; + + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'Authorization' => $this->token ? sprintf('Bearer %s', $this->token) : null, + ], + 'json' => array_filter($options), + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote Expo server.', $response, 0, $e); + } + + $contentType = $response->getHeaders(false)['content-type'][0] ?? ''; + $jsonContents = 0 === strpos($contentType, 'application/json') ? $response->toArray(false) : null; + + if (200 !== $statusCode) { + $errorMessage = $jsonContents['error']['message'] ?? $response->getContent(false); + + throw new TransportException('Unable to post the Expo message: '.$errorMessage, $response); + } + + $success = $response->toArray(false); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($success['data']['id']); + + return $sentMessage; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/ExpoTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Expo/ExpoTransportFactory.php new file mode 100644 index 0000000000000..ecda26cfaedc1 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/ExpoTransportFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Expo; + +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; + +/** + * @author Imad ZAIRIG + */ +final class ExpoTransportFactory extends AbstractTransportFactory +{ + /** + * @return ExpoTransport + */ + public function create(Dsn $dsn): TransportInterface + { + $scheme = $dsn->getScheme(); + + if ('expo' !== $scheme) { + throw new UnsupportedSchemeException($dsn, 'expo', $this->getSupportedSchemes()); + } + + $token = $dsn->getUser($dsn); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new ExpoTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } + + protected function getSupportedSchemes(): array + { + return ['expo']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE b/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE new file mode 100644 index 0000000000000..efb17f98e7dd3 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/README.md b/src/Symfony/Component/Notifier/Bridge/Expo/README.md new file mode 100644 index 0000000000000..2332ce7ddb143 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/README.md @@ -0,0 +1,22 @@ +Expo Notifier +================= + +Provides [Expo](https://docs.expo.dev/versions/latest/sdk/notifications/) integration for Symfony Notifier. + +DSN example +----------- + +``` +EXPO_DSN=expo://TOKEN@default +``` + +where: + - `TOKEN` is your Expo Access Token + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/Tests/ExpoTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Expo/Tests/ExpoTransportFactoryTest.php new file mode 100644 index 0000000000000..c78d9f4fe3594 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/Tests/ExpoTransportFactoryTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Expo\Tests; + +use Symfony\Component\Notifier\Bridge\Expo\ExpoTransportFactory; +use Symfony\Component\Notifier\Test\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; + +/** + * @author Imad ZAIRIG + */ +final class ExpoTransportFactoryTest extends TransportFactoryTestCase +{ + /** + * @return ExpoTransportFactory + */ + public function createFactory(): TransportFactoryInterface + { + return new ExpoTransportFactory(); + } + + public function createProvider(): iterable + { + yield [ + 'expo://exp.host/--/api/v2/push/send', + 'expo://default', + ]; + } + + public function supportsProvider(): iterable + { + yield [true, 'expo://default?accessToken=test']; + yield [false, 'somethingElse://username:password@default']; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://username:password@default']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/Tests/ExpoTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Expo/Tests/ExpoTransportTest.php new file mode 100644 index 0000000000000..4445d9a67cfb2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/Tests/ExpoTransportTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Expo\Tests; + +use Symfony\Component\Notifier\Bridge\Expo\ExpoTransport; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\PushMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Imad ZAIRIG + */ +final class ExpoTransportTest extends TransportTestCase +{ + /** + * @return ExpoTransport + */ + public function createTransport(HttpClientInterface $client = null): TransportInterface + { + return new ExpoTransport('token', $client ?? $this->createMock(HttpClientInterface::class)); + } + + public function toStringProvider(): iterable + { + yield ['expo://exp.host/--/api/v2/push/send', $this->createTransport()]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new PushMessage('Hello!', 'Symfony Notifier')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0670802161', 'Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/composer.json b/src/Symfony/Component/Notifier/Bridge/Expo/composer.json new file mode 100644 index 0000000000000..af15e3c5ec32e --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/expo-notifier", + "type": "symfony-bridge", + "description": "Symfony Expo Notifier Bridge", + "keywords": ["expo", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Zairig Imad", + "homepage": "https://github.com/zairigimad" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^4.3|^5.0|^6.0", + "symfony/notifier": "^5.3|^6.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Expo\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Expo/phpunit.xml.dist new file mode 100644 index 0000000000000..78b296c2e5893 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Expo/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index f4307594e8ecb..3524c0043bf2d 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -36,6 +36,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Esendex\EsendexTransportFactory::class, 'package' => 'symfony/esendex-notifier', ], + 'expo' => [ + 'class' => Bridge\Expo\ExpoTransportFactory::class, + 'package' => 'symfony/expo-notifier', + ], 'fakechat' => [ 'class' => Bridge\FakeChat\FakeChatTransportFactory::class, 'package' => 'symfony/fake-chat-notifier', diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php index bb65d7212b77f..a8ca4c91e6fa2 100644 --- a/src/Symfony/Component/Notifier/Transport.php +++ b/src/Symfony/Component/Notifier/Transport.php @@ -16,6 +16,7 @@ use Symfony\Component\Notifier\Bridge\Clickatell\ClickatellTransportFactory; use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory; use Symfony\Component\Notifier\Bridge\Esendex\EsendexTransportFactory; +use Symfony\Component\Notifier\Bridge\Expo\ExpoTransportFactory; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory; use Symfony\Component\Notifier\Bridge\FreeMobile\FreeMobileTransportFactory; use Symfony\Component\Notifier\Bridge\GatewayApi\GatewayApiTransportFactory; @@ -70,6 +71,7 @@ class Transport ClickatellTransportFactory::class, DiscordTransportFactory::class, EsendexTransportFactory::class, + ExpoTransportFactory::class, FirebaseTransportFactory::class, FreeMobileTransportFactory::class, GatewayApiTransportFactory::class,