Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -77,27 +77,109 @@ class NotificationService
|
||||
public function sendDataNotification(string $userId, array $data, ?string $deviceId = null): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Fetch the Live Activity token alongside the FCM one. They are different
|
||||
// tokens with different delivery semantics: the FCM registration token
|
||||
// wakes the app, while an ActivityKit token is the only thing that can
|
||||
// update a Live Activity on the lock screen.
|
||||
$sql = "SELECT push_token, live_activity_token, platform
|
||||
FROM user_devices
|
||||
WHERE user_id = ? AND push_token IS NOT NULL";
|
||||
$params = [$userId];
|
||||
|
||||
if ($deviceId) {
|
||||
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND push_token IS NOT NULL");
|
||||
$stmt->execute([$userId, $deviceId]);
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT push_token FROM user_devices WHERE user_id = ? AND push_token IS NOT NULL");
|
||||
$stmt->execute([$userId]);
|
||||
$sql .= " AND device_fingerprint = ?";
|
||||
$params[] = $deviceId;
|
||||
}
|
||||
|
||||
$tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
if (empty($tokens)) return false;
|
||||
try {
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$devices = $stmt->fetchAll();
|
||||
} catch (\PDOException $e) {
|
||||
// Deployment has not run the live_activity_token migration yet.
|
||||
$fallbackSql = str_replace(', live_activity_token', '', $sql);
|
||||
$stmt = $db->prepare($fallbackSql);
|
||||
$stmt->execute($params);
|
||||
$devices = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
if (empty($devices)) return false;
|
||||
|
||||
$isLiveActivityUpdate = ($data['type'] ?? '') === 'batch_progress';
|
||||
$successCount = 0;
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->dispatchToFcm($token, null, null, $data)) {
|
||||
|
||||
foreach ($devices as $device) {
|
||||
// 1. Silent data push to the app itself (drives in-app progress and
|
||||
// the Android ongoing notification).
|
||||
if ($this->dispatchToFcm($device['push_token'], null, null, $data)) {
|
||||
$successCount++;
|
||||
}
|
||||
|
||||
// 2. Separate ActivityKit push for the iOS Live Activity.
|
||||
$activityToken = $device['live_activity_token'] ?? null;
|
||||
if ($isLiveActivityUpdate && !empty($activityToken)) {
|
||||
if ($this->dispatchLiveActivityUpdate($activityToken, $data)) {
|
||||
$successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $successCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an ActivityKit content-state update to a running Live Activity.
|
||||
*
|
||||
* The content-state keys must match InvoiceBatchAttributes.ContentState in
|
||||
* the iOS widget extension exactly, or iOS discards the update.
|
||||
*/
|
||||
private function dispatchLiveActivityUpdate(string $activityToken, array $data): bool
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) return false;
|
||||
|
||||
$processed = (int)($data['processed'] ?? 0);
|
||||
$total = max(1, (int)($data['total'] ?? 1));
|
||||
$failed = (int)($data['failed'] ?? 0);
|
||||
$isDone = !empty($data['is_done']);
|
||||
|
||||
$message = [
|
||||
'apns' => [
|
||||
// The token that identifies the ACTIVITY, not the app install.
|
||||
'live_activity_token' => $activityToken,
|
||||
'headers' => [
|
||||
'apns-push-type' => 'liveactivity',
|
||||
'apns-priority' => '10',
|
||||
'apns-topic' => env('IOS_BUNDLE_ID', 'com.musadaq.app') . '.push-type.liveactivity',
|
||||
],
|
||||
'payload' => [
|
||||
'aps' => [
|
||||
'timestamp' => time(),
|
||||
'event' => $isDone ? 'end' : 'update',
|
||||
'content-state' => [
|
||||
'current' => $processed,
|
||||
'total' => $total,
|
||||
'failed' => $failed,
|
||||
'isDone' => $isDone,
|
||||
],
|
||||
// Give iOS a moment to show the final state before it
|
||||
// clears a finished activity.
|
||||
'dismissal-date' => $isDone ? (time() + 30) : null,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Strip nulls: APNs rejects a null dismissal-date.
|
||||
$message['apns']['payload']['aps'] = array_filter(
|
||||
$message['apns']['payload']['aps'],
|
||||
static fn($v) => $v !== null
|
||||
);
|
||||
|
||||
return $this->postToFcm(['message' => $message], 'live-activity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch notification to Firebase via HTTP v1 API
|
||||
*/
|
||||
@@ -108,11 +190,6 @@ class NotificationService
|
||||
return false;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) return false;
|
||||
|
||||
$url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
|
||||
|
||||
$message = [
|
||||
'token' => $token,
|
||||
'data' => array_map('strval', $data),
|
||||
@@ -138,7 +215,11 @@ class NotificationService
|
||||
],
|
||||
];
|
||||
} else {
|
||||
// Silent push / Live Activity Update
|
||||
// Silent data push: wakes the app so it can update its own progress
|
||||
// UI. Live Activity updates are a SEPARATE push that must target the
|
||||
// ActivityKit token - see dispatchLiveActivityUpdate(). Sending
|
||||
// apns-push-type: liveactivity to an app registration token, as this
|
||||
// used to do, never reaches the activity.
|
||||
$message['android'] = [
|
||||
'priority' => 'high'
|
||||
];
|
||||
@@ -153,19 +234,31 @@ class NotificationService
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
// If the data contains live activity update markers, adjust headers for iOS ActivityKit
|
||||
if (isset($data['type']) && $data['type'] === 'batch_progress') {
|
||||
$message['apns']['headers']['apns-push-type'] = 'liveactivity';
|
||||
$message['apns']['headers']['apns-priority'] = '10';
|
||||
$message['apns']['payload']['aps']['content-state'] = $data;
|
||||
$message['apns']['payload']['aps']['timestamp'] = time();
|
||||
$message['apns']['payload']['aps']['event'] = 'update';
|
||||
}
|
||||
}
|
||||
|
||||
$payload = ['message' => $message];
|
||||
return $this->postToFcm(['message' => $message], 'message');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a prepared FCM v1 payload. Shared by the notification, silent-data and
|
||||
* Live Activity paths so auth/error handling lives in one place.
|
||||
*/
|
||||
private function postToFcm(array $payload, string $kind): bool
|
||||
{
|
||||
if (!file_exists($this->serviceAccountPath)) {
|
||||
error_log("[NotificationService] Firebase service account file missing: {$this->serviceAccountPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) return false;
|
||||
|
||||
if (empty($this->projectId)) {
|
||||
error_log('[NotificationService] Firebase project id is empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = "https://fcm.googleapis.com/v1/projects/{$this->projectId}/messages:send";
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
@@ -175,20 +268,57 @@ class NotificationService
|
||||
'Content-Type: application/json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
error_log("[NotificationService] FCM cURL error ($kind): $curlError");
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[NotificationService] FCM Send Error ($httpCode): " . $response);
|
||||
error_log("[NotificationService] FCM Send Error [$kind] ($httpCode): " . $response);
|
||||
|
||||
// A token the server rejected as unregistered will never work again;
|
||||
// clear it so we stop paying for the round trip on every batch.
|
||||
if (in_array($httpCode, [400, 404], true) && str_contains((string)$response, 'UNREGISTERED')) {
|
||||
$this->pruneToken($payload);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a token FCM reported as permanently invalid.
|
||||
*/
|
||||
private function pruneToken(array $payload): void
|
||||
{
|
||||
$token = $payload['message']['token'] ?? null;
|
||||
$activityToken = $payload['message']['apns']['live_activity_token'] ?? null;
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
if ($token) {
|
||||
$db->prepare("UPDATE user_devices SET push_token = NULL WHERE push_token = ?")
|
||||
->execute([$token]);
|
||||
error_log('[NotificationService] Pruned unregistered push token');
|
||||
}
|
||||
if ($activityToken) {
|
||||
$db->prepare("UPDATE user_devices SET live_activity_token = NULL WHERE live_activity_token = ?")
|
||||
->execute([$activityToken]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[NotificationService] pruneToken failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth2 Access Token for Firebase using Service Account JWT
|
||||
* Self-contained: no external libraries needed.
|
||||
|
||||
Reference in New Issue
Block a user