1+ <?php
2+
3+ namespace Quantomtech \LaravelFirebaseBatchMessaging ;
4+
5+ use Exception ;
6+ use Firebase \JWT \JWT ;
7+ use Illuminate \Http \Client \Response ;
8+ use Illuminate \Support \Arr ;
9+ use Illuminate \Support \Str ;
10+ use Illuminate \Support \Facades \Http ;
11+ use Illuminate \Support \Facades \Storage ;
12+
13+ class FCMBatch
14+ {
15+ /**
16+ * Maximum payload that can be per API call as per documented
17+ */
18+ const MAX_PAYLOAD = 500 ;
19+
20+ /**
21+ * Batch file folder path
22+ *
23+ */
24+ protected $ tempFolderPath ;
25+
26+ /**
27+ * JSON file downloaded when creating Google Cloud Service Account
28+ *
29+ * @var string
30+ */
31+ protected $ credentialFilePath ;
32+
33+ /**
34+ * Notification sound file name
35+ *
36+ * @var string
37+ */
38+ protected $ soundFileName ;
39+
40+ /**
41+ * Batch file unique name
42+ *
43+ * @var string
44+ */
45+ protected $ batchFileTempName ;
46+
47+
48+ /**
49+ * Payload array to be map
50+ *
51+ */
52+ public $ payload = [];
53+
54+ /**
55+ *
56+ */
57+ public function __construct ()
58+ {
59+ $ this ->credentialFilePath = config ('fcm-batch.credential_path ' );
60+ $ this ->tempFolderPath = config ('fcm-batch.temp_file_path ' );
61+ $ this ->soundFileName = config ('fcm-batch.fcm_sound ' );
62+
63+ $ this ->batchFileTempName = 'batch_request_ ' . Str::random (5 ) . '.txt ' ;
64+ }
65+
66+ /**
67+ * Append boundry to request file
68+ *
69+ */
70+ private function appendBoundry (): void
71+ {
72+ Storage::disk ('local ' )->append ($ this ->tempFolderPath . $ this ->batchFileTempName , "\n--subrequest_boundary " );
73+ }
74+
75+ /**
76+ * Append payload to request file
77+ *
78+ */
79+ private function appendPayload (string $ json ):void
80+ {
81+ Storage::disk ('local ' )->append ($ this ->tempFolderPath . $ this ->batchFileTempName , "\n--subrequest_boundary \nContent-Type: application/http \nContent-Transfer-Encoding: binary \n\nPOST /v1/projects/meniaga-app/messages:send \nContent-Type: application/json \naccept: application/json \n\n" . $ json );
82+ }
83+
84+ /**
85+ * Remove generated request file
86+ */
87+ private function removeTempRequestFile (): void
88+ {
89+ Storage::disk ('local ' )->delete ($ this ->tempFolderPath . $ this ->batchFileTempName );
90+ }
91+
92+ /**
93+ * Create OAuth JWT Token
94+ * Maximum validity is 1 Hour
95+ *
96+ * @param int $periodSeconds
97+ */
98+ public function createJWTToken (int $ periodSeconds = 3600 ): string
99+ {
100+ if ($ periodSeconds > 3600 ) {
101+ throw new Exception ('Valid JWT period cannot more than 1 Hour. ' );
102+ }
103+
104+ $ content = file_get_contents ($ this ->credentialFilePath );
105+ $ jsonObj = json_decode ($ content );
106+
107+ $ iat = time ();
108+ $ exp = $ iat + $ periodSeconds ;
109+
110+ return JWT ::encode ([
111+ "iss " => $ jsonObj ->client_email ,
112+ "aud " => $ jsonObj ->token_uri ,
113+ "scope " => 'https://www.googleapis.com/auth/firebase.messaging ' ,
114+ 'iat ' => $ iat ,
115+ 'exp ' => $ exp
116+ ], $ jsonObj ->private_key , "RS256 " );
117+ }
118+
119+ /**
120+ * Fetch API access token
121+ */
122+ private function getAccessToken (): string
123+ {
124+ $ jwtToken = $ this ->createJWTToken ();
125+
126+ $ res = Http::acceptJson ()
127+ ->asForm ()
128+ ->post ('https://oauth2.googleapis.com/token ' , [
129+ 'grant_type ' => 'urn:ietf:params:oauth:grant-type:jwt-bearer ' ,
130+ 'assertion ' => $ jwtToken
131+ ]);
132+
133+ if ($ res ->successful ()) {
134+
135+ /**
136+ * Sometimes, trailing dots returned, need to trim
137+ * https://stackoverflow.com/questions/68654502/why-am-i-getting-a-jwt-with-a-bunch-of-periods-dots-back-from-google-oauth
138+ *
139+ */
140+ $ body = $ res ->json ();
141+
142+ return rtrim ($ body ["access_token " ], '. ' );
143+ }
144+
145+ throw new Exception ('Failed to fetch access token. Body: ' . $ res ->body ());
146+ }
147+
148+
149+ /**
150+ * Construct IOS Payload
151+ *
152+ * @return $this
153+ */
154+ private function addIosPayload (
155+ string $ fcmToken ,
156+ string $ title ,
157+ string $ body ,
158+ ?array $ data
159+ ){
160+ $ setPayload = [
161+ 'message ' =>[
162+ 'token ' => $ fcmToken ,
163+ 'notification ' =>[
164+ 'title ' => $ title ,
165+ 'body ' => $ body
166+ ],
167+ ]
168+ ];
169+
170+ if ($ data ){
171+ $ setPayload ['message ' ]['data ' ] = $ data ;
172+ }
173+
174+ $ this ->payload [] = $ setPayload ;
175+
176+ return $ this ;
177+ }
178+
179+ /**
180+ * Construct Android Payload
181+ *
182+ * @return $this
183+ */
184+ private function addAndroidPayload (
185+ string $ fcmToken ,
186+ string $ title ,
187+ string $ body ,
188+ ?array $ data
189+ ){
190+ $ setPayload = [
191+ 'message ' =>[
192+ 'token ' => $ fcmToken ,
193+ 'notification ' =>[
194+ 'title ' => $ title ,
195+ 'body ' => $ body
196+ ],
197+ 'android ' => [
198+ 'collapse_key ' => $ title ,
199+ 'priority ' => 'high ' ,
200+ 'ttl ' => '0s ' ,
201+ 'notification ' => [
202+ "sound " => $ this ->soundFileName
203+ ]
204+ ]
205+ ]
206+ ];
207+
208+ if ($ data ){
209+ $ setPayload ['message ' ]['data ' ] = $ data ;
210+ }
211+
212+ $ this ->payload [] = $ setPayload ;
213+
214+ return $ this ;
215+ }
216+
217+ /**
218+ * Add payload
219+ * https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages
220+ *
221+ * @param string title
222+ * @param string body
223+ * @param array data
224+ *
225+ * @return $this
226+ */
227+ public function addPayload (
228+ string $ fcmToken ,
229+ string $ title ,
230+ string $ body ,
231+ array $ data = null ,
232+ bool $ IosPayload = false
233+ ) {
234+ if ($ IosPayload ) {
235+ return $ this ->addIosPayload ( $ fcmToken , $ title , $ body , $ data );
236+ }
237+
238+ return $ this ->addAndroidPayload ( $ fcmToken , $ title , $ body , $ data );
239+ }
240+
241+ /**
242+ * Add custom payload data
243+ * https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages
244+ *
245+ * @param array $data require 'title', 'body' in array
246+ *
247+ * @return $this
248+ */
249+ public function addCustomPayload (array $ payload )
250+ {
251+ $ token = Arr::get ($ payload , 'message.token ' , null );
252+ $ title = Arr::get ($ payload , 'message.notification.title ' , null );
253+ $ body = Arr::get ($ payload , 'message.notification.body ' , null );
254+
255+ if (is_null ($ token ) || is_null ($ title ) || is_null ($ body )){
256+ throw new Exception ('"fcm token", "body" and "title" are required ' );
257+ }
258+
259+ $ this ->payload [] = $ payload ;
260+
261+ return $ this ;
262+ }
263+
264+ /**
265+ * Send batch payload
266+ *
267+ */
268+ public function send (): Response
269+ {
270+ // check total payload
271+ if (count ($ this ->payload ) > self ::MAX_PAYLOAD ) {
272+ throw new Exception ("Total Payload per API call cannot exceed " . self ::MAX_PAYLOAD ." messages " );
273+ }
274+
275+ // patch payload to file
276+ foreach ($ this ->payload as $ payload ) {
277+ $ this ->appendPayload (json_encode ($ payload ));
278+ }
279+
280+ $ this ->appendBoundry ();
281+
282+ // send
283+ $ accessToken = $ this ->getAccessToken ();
284+
285+ $ contents = Storage::disk ('local ' )->get ($ this ->tempFolderPath . $ this ->batchFileTempName );
286+
287+ $ res = Http::attach ('attachment ' , $ contents )
288+ ->withHeaders ([
289+ 'Authorization ' => "Bearer " . $ accessToken ,
290+ 'Content-Type ' => 'multipart/mixed; boundary="subrequest_boundary" '
291+ ])
292+ ->post ('https://fcm.googleapis.com/batch ' );
293+
294+
295+ // remove temp file
296+ $ this ->removeTempRequestFile ();
297+
298+ return $ res ;
299+ }
300+ }
0 commit comments