diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 62ab3e5e6a3aa..dfc10d0c29f21 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -109,6 +109,7 @@ jobs: Copy c:\php\php.ini-min c:\php\php.ini Remove-Item -Path src\Symfony\Bridge\PhpUnit -Recurse + Remove-Item -Path src\Symfony\Component\Notifier\Bridge\Firebase -Recurse mv src\Symfony\Component\HttpClient\phpunit.xml.dist src\Symfony\Component\HttpClient\phpunit.xml php phpunit src\Symfony --exclude-group tty,benchmark,intl-data,network,transient-on-windows || ($x = 1) # HttpClient tests need to run separately, they block when run with other components' tests concurrently diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md index 5b5417f3c604a..f26089a5d8aec 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +7.3 +--- + + * Changed to using the new v1 Firebase API + * [DEPRECATION] `AndroidNotification`, `IOSNotification` and `WebNotification` were deprecated and will be removed in 8.0, use `FirebaseOptions` instead. New Firebase API allows for all platform specific options to be specified for the message. Having options separated by platforms now limits usability. + * [DEPRECATION] Using DSN for firebase in the old format `firebase://username:password@default` was deprecated, use the new DSN format and specify the required options instead. Not specifying the options will throw error in 8.0. + * [DEPRECATION] `$token` param in `FirebaseTransport::__construct()` was marked as deprecated because the new Firebase API does not use standard token auth. The param will be removed in 8.0. + 5.3 --- diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php index 1b3c67cf1da3a..0f947f256f63c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseOptions.php @@ -15,36 +15,35 @@ /** * @author Jeroen Spee + * @author Vojtech Smejkal * - * @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages */ -abstract class FirebaseOptions implements MessageOptionsInterface +class FirebaseOptions implements MessageOptionsInterface { /** - * @see https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref.html#notification-payload-support + * @param array $data arbitrary meta-data (both keys and values must be a string) */ - protected array $options; - public function __construct( - private string $to, - array $options, - private array $data = [], + private string $target, + protected array $options = [], + array $data = [], + protected TargetType $targetType = TargetType::Topic, ) { - $this->options = $options; + $this->options['data'] = $data; } public function toArray(): array { - return [ - 'to' => $this->to, - 'notification' => $this->options, - 'data' => $this->data, - ]; + $options = $this->options; + $options[$this->targetType->value] = $this->target; + + return $options; } public function getRecipientId(): ?string { - return $this->to; + return '['.$this->targetType->value.']'.$this->target; } /** @@ -52,7 +51,7 @@ public function getRecipientId(): ?string */ public function title(string $title): static { - $this->options['title'] = $title; + $this->addNotificationOption('title', $title); return $this; } @@ -62,17 +61,163 @@ public function title(string $title): static */ public function body(string $body): static { - $this->options['body'] = $body; + $this->addNotificationOption('body', $body); + + return $this; + } + + /** + * @param string $image URL of an image that is going to be downloaded on the device and displayed in a notification + * + * @return $this + */ + public function image(string $image): static + { + $this->addNotificationOption('image', $image); return $this; } /** + * @param array $data + * * @return $this */ public function data(array $data): static { - $this->data = $data; + $this->options['data'] = $data; + + return $this; + } + + /** + * @param array $notification + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification + */ + public function notification(array $notification): static + { + $this->options['notification'] = $notification; + + return $this; + } + + /** + * @param array $android + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig + */ + public function android(array $android): static + { + $this->options['android'] = $android; + + return $this; + } + + /** + * @param array $webpush + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig + */ + public function webpush(array $webpush): static + { + $this->options['webpush'] = $webpush; + + return $this; + } + + /** + * @param array $apns + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig + */ + public function apns(array $apns): static + { + $this->options['apns'] = $apns; + + return $this; + } + + /** + * @param array $fcmOptions + * + * @return $this + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions + */ + public function fcmOptions(array $fcmOptions): static + { + $this->options['fcm_options'] = $fcmOptions; + + return $this; + } + + /** + * @return $this + */ + protected function addNotificationOption(string $key, mixed $value): static + { + $this->options['notification'] ??= []; + $this->options['notification'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addAndroidOption(string $key, mixed $value): static + { + $this->options['android'] ??= []; + $this->options['android']['notification'] ??= []; + $this->options['android']['notification'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addWebpushOption(string $key, mixed $value): static + { + $this->options['webpush'] ??= []; + $this->options['webpush']['notification'] ??= []; + $this->options['webpush']['notification'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addApnsOption(string $key, mixed $value): static + { + $this->options['apns'] ??= []; + $this->options['apns']['payload'] ??= []; + $this->options['apns']['payload']['aps'] ??= []; + $this->options['apns']['payload']['aps'][$key] = $value; + + return $this; + } + + /** + * @return $this + */ + protected function addApnsAlertOption(string $key, mixed $value): static + { + $this->options['apns'] ??= []; + $this->options['apns']['payload'] ??= []; + $this->options['apns']['payload']['aps'] ??= []; + $this->options['apns']['payload']['aps']['alert'] ??= []; + $this->options['apns']['payload']['aps']['alert'][$key] = $value; return $this; } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php index 25e5864a913b8..dac8f89ae607c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransport.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Notifier\Bridge\Firebase; +use Symfony\Component\Notifier\Exception\IncompleteDsnException; use Symfony\Component\Notifier\Exception\InvalidArgumentException; use Symfony\Component\Notifier\Exception\TransportException; use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; @@ -24,16 +25,32 @@ /** * @author Jeroen Spee + * @author Vojtech Smejkal + * + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send */ final class FirebaseTransport extends AbstractTransport { - protected const HOST = 'fcm.googleapis.com/fcm/send'; + protected const HOST = 'fcm.googleapis.com'; public function __construct( #[\SensitiveParameter] private string $token, + #[\SensitiveParameter] private string $projectId = '', + #[\SensitiveParameter] private string $clientEmail = '', + #[\SensitiveParameter] private string $privateKeyId = '', + #[\SensitiveParameter] private string $privateKey = '', ?HttpClientInterface $client = null, ?EventDispatcherInterface $dispatcher = null, ) { + if ('' !== $this->token) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'The $token parameter in "%s" is deprecated, use $projectId, $clientEmail, $privateKeyId and $privateKey instead.', + self::class, + ); + } + parent::__construct($client, $dispatcher); } @@ -53,48 +70,90 @@ protected function doSend(MessageInterface $message): SentMessage throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); } - $endpoint = \sprintf('https://%s', $this->getEndpoint()); - $options = $message->getOptions()?->toArray() ?? []; - $options['to'] = $message->getRecipientId(); - - if (!$options['to']) { - throw new InvalidArgumentException(\sprintf('The "%s" transport required the "to" option to be set.', __CLASS__)); + if (!$this->projectId || !$this->privateKeyId || !$this->privateKey) { + throw new IncompleteDsnException(\sprintf('The "%s" transport requires project_id, private_key_id and private_key options to be specified in DSN.', self::class)); } + + $endpoint = \sprintf('https://%s/v1/projects/%s/messages:send', $this->getEndpoint(), $this->projectId); + + $options = $message->getOptions()?->toArray() ?? []; + $options['notification'] ??= []; $options['notification']['body'] = $message->getSubject(); $options['data'] ??= []; + if (!isset($options['token']) && !isset($options['topic']) && !isset($options['condition'])) { + throw new InvalidArgumentException(\sprintf('The "%s" transport requires the "token", "topic" or "condition" option to be set.', self::class)); + } + + // Send $response = $this->client->request('POST', $endpoint, [ - 'headers' => [ - 'Authorization' => \sprintf('key=%s', $this->token), - ], - 'json' => array_filter($options), + 'headers' => ['Authorization' => \sprintf('Bearer %s', $this->getJwt())], + 'json' => ['message' => $options], ]); try { $statusCode = $response->getStatusCode(); } catch (TransportExceptionInterface $e) { - throw new TransportException('Could not reach the remote Firebase server.', $response, 0, $e); + throw new TransportException('Sending message to Firebase failed.', $response, 0, $e); } $contentType = $response->getHeaders(false)['content-type'][0] ?? ''; - $jsonContents = str_starts_with($contentType, 'application/json') ? $response->toArray(false) : null; + $jsonContents = \str_starts_with($contentType, 'application/json') ? $response->toArray(false) : null; $errorMessage = null; - if ($jsonContents && isset($jsonContents['results'][0]['error'])) { - $errorMessage = $jsonContents['results'][0]['error']; + if (null !== $jsonContents && isset($jsonContents['error']['message'])) { + $errorMessage = $jsonContents['error']['message']; } elseif (200 !== $statusCode) { $errorMessage = $response->getContent(false); } if (null !== $errorMessage) { - throw new TransportException('Unable to post the Firebase message: '.$errorMessage, $response); + throw new TransportException(\sprintf('Firebase server responded with error "%s"', $errorMessage), $response); } $success = $response->toArray(false); $sentMessage = new SentMessage($message, (string) $this); - $sentMessage->setMessageId($success['results'][0]['message_id'] ?? ''); + $sentMessage->setMessageId($success['name'] ?? ''); return $sentMessage; } + + private function getJwt(): string + { + $time = time(); + $header = $this->base64UrlEncode([ + 'alg' => 'RS256', + 'typ' => 'JWT', + ]); + $payload = $this->base64UrlEncode([ + 'iss' => $this->clientEmail, + 'sub' => $this->clientEmail, + 'aud' => 'https://fcm.googleapis.com/', + 'iat' => $time, + 'exp' => $time + 3600, + 'kid' => $this->privateKeyId, + ]); + $key = \openssl_pkey_get_private($this->privateKey); + + if (false === $key) { + throw new InvalidArgumentException(\sprintf('The "%s" transport could not load private key from DSN. Is the key valid?', self::class)); + } + + \openssl_sign($header.'.'.$payload, $signature, $key, \OPENSSL_ALGO_SHA256); + + return $header.'.'.$payload.'.'.$this->base64UrlEncode($signature); + } + + /** + * @param string|array $data + */ + private function base64UrlEncode(string|array $data): string + { + if (\is_array($data)) { + $data = \json_encode($data, \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR); + } + + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php index b7b4fe94fe9ec..8bc1e13bbbb38 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/FirebaseTransportFactory.php @@ -11,12 +11,14 @@ namespace Symfony\Component\Notifier\Bridge\Firebase; +use Symfony\Component\Notifier\Exception\MissingRequiredOptionException; use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; /** * @author Jeroen Spee + * @author Vojtech Smejkal */ final class FirebaseTransportFactory extends AbstractTransportFactory { @@ -28,11 +30,29 @@ public function create(Dsn $dsn): FirebaseTransport throw new UnsupportedSchemeException($dsn, 'firebase', $this->getSupportedSchemes()); } - $token = \sprintf('%s:%s', $this->getUser($dsn), $this->getPassword($dsn)); $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); $port = $dsn->getPort(); - - return (new FirebaseTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + $user = $this->getUser($dsn); + + try { + $projectId = $dsn->getRequiredOption('project_id'); + $privateKeyId = $dsn->getRequiredOption('private_key_id'); + $privateKey = $dsn->getRequiredOption('private_key'); + + return (new FirebaseTransport('', $projectId, $user, $privateKeyId, $privateKey, $this->client, $this->dispatcher)) + ->setHost($host) + ->setPort($port); + } catch (MissingRequiredOptionException) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using Firebase Notifier without project_id, private_key_id and private_key options is deprecated. Update your Firebase DSN.', + ); + + return (new FirebaseTransport('', '', '', '', '', $this->client, $this->dispatcher)) + ->setHost($host) + ->setPort($port); + } } protected function getSupportedSchemes(): array diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php index e5b193834de19..46cd8387ee397 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/AndroidNotification.php @@ -12,17 +12,33 @@ namespace Symfony\Component\Notifier\Bridge\Firebase\Notification; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; final class AndroidNotification extends FirebaseOptions { + public function __construct( + string $target, + array $options = [], + array $data = [], + TargetType $targetType = TargetType::Topic, + ) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using %s class is deprecated, use %s instead.', + self::class, + FirebaseOptions::class, + ); + + parent::__construct($target, ['notification' => $options], $data, $targetType); + } + /** * @return $this */ public function channelId(string $channelId): static { - $this->options['android_channel_id'] = $channelId; - - return $this; + return $this->addAndroidOption('channel_id', $channelId); } /** @@ -30,9 +46,7 @@ public function channelId(string $channelId): static */ public function icon(string $icon): static { - $this->options['icon'] = $icon; - - return $this; + return $this->addAndroidOption('icon', $icon); } /** @@ -40,9 +54,7 @@ public function icon(string $icon): static */ public function sound(string $sound): static { - $this->options['sound'] = $sound; - - return $this; + return $this->addAndroidOption('sound', $sound); } /** @@ -50,9 +62,7 @@ public function sound(string $sound): static */ public function tag(string $tag): static { - $this->options['tag'] = $tag; - - return $this; + return $this->addAndroidOption('tag', $tag); } /** @@ -60,9 +70,7 @@ public function tag(string $tag): static */ public function color(string $color): static { - $this->options['color'] = $color; - - return $this; + return $this->addAndroidOption('color', $color); } /** @@ -70,9 +78,7 @@ public function color(string $color): static */ public function clickAction(string $clickAction): static { - $this->options['click_action'] = $clickAction; - - return $this; + return $this->addAndroidOption('click_action', $clickAction); } /** @@ -80,9 +86,7 @@ public function clickAction(string $clickAction): static */ public function bodyLocKey(string $bodyLocKey): static { - $this->options['body_loc_key'] = $bodyLocKey; - - return $this; + return $this->addAndroidOption('body_loc_key', $bodyLocKey); } /** @@ -92,9 +96,7 @@ public function bodyLocKey(string $bodyLocKey): static */ public function bodyLocArgs(array $bodyLocArgs): static { - $this->options['body_loc_args'] = $bodyLocArgs; - - return $this; + return $this->addAndroidOption('body_loc_args', $bodyLocArgs); } /** @@ -102,9 +104,7 @@ public function bodyLocArgs(array $bodyLocArgs): static */ public function titleLocKey(string $titleLocKey): static { - $this->options['title_loc_key'] = $titleLocKey; - - return $this; + return $this->addAndroidOption('title_loc_key', $titleLocKey); } /** @@ -114,8 +114,6 @@ public function titleLocKey(string $titleLocKey): static */ public function titleLocArgs(array $titleLocArgs): static { - $this->options['title_loc_args'] = $titleLocArgs; - - return $this; + return $this->addAndroidOption('title_loc_args', $titleLocArgs); } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php index 7ef34f11c452c..d3bd7b341f4a3 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/IOSNotification.php @@ -12,17 +12,33 @@ namespace Symfony\Component\Notifier\Bridge\Firebase\Notification; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; final class IOSNotification extends FirebaseOptions { + public function __construct( + string $target, + array $options = [], + array $data = [], + TargetType $targetType = TargetType::Topic, + ) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using %s class is deprecated, use %s instead.', + self::class, + FirebaseOptions::class, + ); + + parent::__construct($target, ['notification' => $options], $data, $targetType); + } + /** * @return $this */ public function sound(string $sound): static { - $this->options['sound'] = $sound; - - return $this; + return $this->addApnsOption('sound', $sound); } /** @@ -30,9 +46,7 @@ public function sound(string $sound): static */ public function badge(string $badge): static { - $this->options['badge'] = $badge; - - return $this; + return $this->addApnsOption('badge', $badge); } /** @@ -40,9 +54,7 @@ public function badge(string $badge): static */ public function clickAction(string $clickAction): static { - $this->options['click_action'] = $clickAction; - - return $this; + return $this->addApnsOption('category', $clickAction); } /** @@ -50,9 +62,7 @@ public function clickAction(string $clickAction): static */ public function subtitle(string $subtitle): static { - $this->options['subtitle'] = $subtitle; - - return $this; + return $this->addApnsAlertOption('subtitle', $subtitle); } /** @@ -60,9 +70,7 @@ public function subtitle(string $subtitle): static */ public function bodyLocKey(string $bodyLocKey): static { - $this->options['body_loc_key'] = $bodyLocKey; - - return $this; + return $this->addApnsAlertOption('body_loc_key', $bodyLocKey); } /** @@ -72,9 +80,7 @@ public function bodyLocKey(string $bodyLocKey): static */ public function bodyLocArgs(array $bodyLocArgs): static { - $this->options['body_loc_args'] = $bodyLocArgs; - - return $this; + return $this->addApnsAlertOption('body_loc_args', $bodyLocArgs); } /** @@ -82,9 +88,7 @@ public function bodyLocArgs(array $bodyLocArgs): static */ public function titleLocKey(string $titleLocKey): static { - $this->options['title_loc_key'] = $titleLocKey; - - return $this; + return $this->addApnsAlertOption('title_loc_key', $titleLocKey); } /** @@ -94,8 +98,6 @@ public function titleLocKey(string $titleLocKey): static */ public function titleLocArgs(array $titleLocArgs): static { - $this->options['title_loc_args'] = $titleLocArgs; - - return $this; + return $this->addApnsAlertOption('title_loc_args', $titleLocArgs); } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php index d925317f8e24a..0ce437344e308 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Notification/WebNotification.php @@ -12,17 +12,33 @@ namespace Symfony\Component\Notifier\Bridge\Firebase\Notification; use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; final class WebNotification extends FirebaseOptions { + public function __construct( + string $target, + array $options = [], + array $data = [], + TargetType $targetType = TargetType::Topic, + ) { + \trigger_deprecation( + 'symfony/firebase-notifier', + '7.3', + 'Using %s class is deprecated, use %s instead.', + self::class, + FirebaseOptions::class, + ); + + parent::__construct($target, ['notification' => $options], $data, $targetType); + } + /** * @return $this */ public function icon(string $icon): static { - $this->options['icon'] = $icon; - - return $this; + return $this->addWebpushOption('icon', $icon); } /** @@ -30,8 +46,6 @@ public function icon(string $icon): static */ public function clickAction(string $clickAction): static { - $this->options['click_action'] = $clickAction; - - return $this; + return $this->addWebpushOption('click_action', $clickAction); } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/README.md b/src/Symfony/Component/Notifier/Bridge/Firebase/README.md index 7ff2c71575c88..95f1889846976 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/README.md +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/README.md @@ -7,32 +7,107 @@ DSN example ----------- ``` -FIREBASE_DSN=firebase://USERNAME:PASSWORD@default +FIREBASE_DSN=firebase://@default?project_id=&private_key_id=&private_key= +``` + +All 4 parameters are required. All 4 parameters are located inside your firebase json private key. + +IMPORTANT: Make sure that `PRIVATE_KEY` is safely url encoded. Get more information in [Notifier documentation](https://symfony.com/doc/current/notifier.html#chat-channel) or use the script below. + + +Getting credentials for your DSN +-------------------------------- + +Steps for getting your private key: + 1. Log into the [firebase console](https://console.firebase.google.com/). + 2. Click on your project + 3. Click on the gear icon next to "Project Overview" and click on "Project settings" + 4. Click on "Service accounts" tab + 5. Click on "Generate new private key" button at the bottom + 6. A JSON file with your private key will be downloaded + +The downloaded private key is a JSON file which should contain the following keys: + * `type` + * `project_id` + * `private_key_id` + * `private_key` + * `client_email` + * `client_id` + * `auth_uri` + * `token_uri` + * `auth_provider_x509_cert_url` + * `client_x509_cert_url` + * `universe_domain` + +You can then use the following script to convert your JSON private key to DSN format: + +```php +icon('myicon') - ->sound('default') - ->tag('myNotificationId') - ->color('#cccccc') - ->clickAction('OPEN_ACTIVITY_1') +// Specify options for Firebase +$firebaseOptions = (new FirebaseOptions('/topics/news')) + ->title('New message!') + ->body('This will overwrite the subject from ChatMessage object.') + ->image('https://path-to-your-image.png') + ->data([ + 'key1' => 'value1', + 'key2' => 'value2', + ]) // ... ; @@ -42,6 +117,67 @@ $chatMessage->options($androidOptions); $chatter->send($chatMessage); ``` +Firebase offers 3 different types of targets to send the message to: + * token + * topic + * condition + +How to specify different targets for firebase messages: + +```php +use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; + +// Use a topic as a target +$topicOptions = new FirebaseOptions('/topics/news', targetType: TargetType::Topic); + +// Use a token as a target +$tokenOptions = new FirebaseOptions('dU5e3nFJf9bE:APA91bH3Kd/exampleToken', targetType: TargetType::Token); + +// Use a condition as a target +$conditionOptions = new FirebaseOptions('\'news\' in topics', targetType: TargetType::Condition); +``` + +Firebase also allows specifying platform specific options. They can be specified as follows: + +```php +use Symfony\Component\Notifier\Bridge\Firebase\FirebaseOptions; +use Symfony\Component\Notifier\Bridge\Firebase\TargetType; + +$detailedOptions = (new FirebaseOptions('dU5e3nFJf9bE:APA91bH3Kd/exampleToken', tergetType: TargetType::Token)) + // Basic options + ->title('New message!') + ->data([ + 'key1' => 'value1', + 'key2' => 'value2', + ]) + // Android specific options + ->android([ + 'notification' => [ + 'color' => '#4538D5', + ], + ]) + // WebPush specific options + ->webpush([ + 'notification' => [ + 'icon' => 'https://path-to-an-icon.png', + ], + ]) + // APNS specific options + ->apns([ + 'payload' => [ + 'aps' => [ + 'sound' => 'default', + ], + ], + ]) + // ... + ; +``` + +For all available options please refer to the [firebase documentation](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages). + + Resources --------- diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php b/src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php new file mode 100644 index 0000000000000..15d522052a37a --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/TargetType.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Firebase; + +/** + * @author Vojtech Smejkal + */ +enum TargetType: string +{ + case Topic = 'topic'; + case Token = 'token'; + case Condition = 'condition'; +} diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php index f1a73e1fa6357..f873f78351209 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportFactoryTest.php @@ -17,6 +17,7 @@ /** * @author Oskar Stark + * @author Vojtech Smejkal */ final class FirebaseTransportFactoryTest extends AbstractTransportFactoryTestCase { @@ -30,14 +31,15 @@ public function createFactory(): FirebaseTransportFactory public static function createProvider(): iterable { yield [ - 'firebase://host.test', - 'firebase://username:password@host.test', + 'firebase://fcm.googleapis.com', + 'firebase://firebase-adminsdk@iam.gserviceaccount.com@default?project_id=PROJECT_ID&private_key_id=PRIVATE_KEY_ID&private_key=PRIVATE_KEY', ]; } public static function supportsProvider(): iterable { yield [true, 'firebase://username:password@default']; + yield [true, 'firebase://firebase-adminsdk@iam.gserviceaccount.com@default?project_id=PROJECT_ID&private_key_id=PRIVATE_KEY_ID&private_key=PRIVATE_KEY']; yield [false, 'somethingElse://username:password@default']; } @@ -49,6 +51,5 @@ public static function unsupportedSchemeProvider(): iterable public static function incompleteDsnProvider(): iterable { yield ['firebase://:password@default']; - yield ['firebase://username@default']; } } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php index 704e9b9212ee4..971faade57f01 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/Tests/FirebaseTransportTest.php @@ -25,17 +25,25 @@ /** * @author Oskar Stark + * @author Vojtech Smejkal */ final class FirebaseTransportTest extends TransportTestCase { public static function createTransport(?HttpClientInterface $client = null): FirebaseTransport { - return new FirebaseTransport('username:password', $client ?? new MockHttpClient()); + return new FirebaseTransport( + '', + 'test_project_id', + 'firebase-adminsdk-test@test-project.iam.gserviceaccount.com', + 'private_key_id', + "-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmU2f/GKCLuvw8NAl\nbqJW5RxhMrUrcampGQxz2F2OT3fqoyKBhAGzNhxbgPZYDeXp7WNNLTk9WLT7sDNM\ndjVUuQIDAQABAkAtOTX52QF4YAfKskxoj6E8oxuVPtabCCanCgJekHK7xDpYpYre\ncvxoPhw0c4McZiFoBOlr0TqyY9qpDWXDHLDFAiEAy9jNU/N438WrurUPuOfyqIYx\n25NJWQ7sgjfDJBMVHO8CIQDAhmed4Uih7QKZAMPeRayFeemdjcXNmN4wp/YjiLZ4\n1wIgaTWnnBnAnDYo0T+cMsI8QvCoEP0u0TFbrkXbiOX0cq8CIQCrg9GxrG75mt1y\nk2TrkuS0cLy4GQJ8PFDNxgSY+YWeNwIgavjv+v6MgyLrMTuZsAd67+5Z5axjdJL8\nbwLzq+QOXk8=\n-----END PRIVATE KEY-----\n", + $client ?? new MockHttpClient(), + ); } public static function toStringProvider(): iterable { - yield ['firebase://fcm.googleapis.com/fcm/send', self::createTransport()]; + yield ['firebase://fcm.googleapis.com', self::createTransport()]; } public static function supportedMessagesProvider(): iterable @@ -67,12 +75,12 @@ public function testSendWithErrorThrowsTransportException(ResponseInterface $res public static function sendWithErrorThrowsExceptionProvider(): iterable { yield [new MockResponse( - json_encode(['results' => [['error' => 'testErrorCode']]]), + json_encode(['error' => ['message' => 'testErrorCode']]), ['response_headers' => ['content-type' => ['application/json']], 'http_code' => 200] )]; yield [new MockResponse( - json_encode(['results' => [['error' => 'testErrorCode']]]), + json_encode(['error' => ['message' => 'testErrorCode']]), ['response_headers' => ['content-type' => ['application/json']], 'http_code' => 400] )]; } diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json index fa18127a3f874..be95dfe6d513c 100644 --- a/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Firebase/composer.json @@ -17,6 +17,8 @@ ], "require": { "php": ">=8.2", + "ext-openssl": "*", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client": "^6.4|^7.0", "symfony/notifier": "^7.2" },