Skip to content

Commit 0237e2a

Browse files
committed
add FCMBatch main file
1 parent d204045 commit 0237e2a

File tree

1 file changed

+300
-0
lines changed

1 file changed

+300
-0
lines changed

src/FCMBatch.php

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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

Comments
 (0)