serviceAccountPath = env('FIREBASE_SERVICE_ACCOUNT_PATH', APP_PATH . '/config/firebase-service-account.json'); // Auto-detect Project ID from Service Account JSON to prevent RESOURCE_PROJECT_INVALID if (file_exists($this->serviceAccountPath)) { $sa = json_decode(file_get_contents($this->serviceAccountPath), true); $this->projectId = $sa['project_id'] ?? env('FIREBASE_PROJECT_ID', ''); } else { $this->projectId = env('FIREBASE_PROJECT_ID', ''); } } /** * Send a push notification to a specific user or device */ public function sendNotification(string $userId, string $title, string $body, array $data = [], ?string $deviceId = null): bool { $db = Database::getInstance(); // 1. Get push tokens for the user 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]); } $tokens = $stmt->fetchAll(\PDO::FETCH_COLUMN); if (empty($tokens)) { return false; } // 2. Save notification to database (Single direct insert) $stmt = $db->prepare("SELECT tenant_id FROM users WHERE id = ? LIMIT 1"); $stmt->execute([$userId]); $tenantId = $stmt->fetchColumn(); if ($tenantId) { $stmt = $db->prepare("INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at) VALUES (UUID(), ?, ?, 'system', ?, ?, ?, NOW())"); $stmt->execute([$tenantId, $userId, $title, $body, json_encode($data)]); } // 3. Send to each token $successCount = 0; foreach ($tokens as $token) { if ($this->dispatchToFcm($token, $title, $body, $data)) { $successCount++; } } return $successCount > 0; } /** * Send a data-only (silent) notification to update background state (e.g., progress) */ 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) { $sql .= " AND device_fingerprint = ?"; $params[] = $deviceId; } 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 ($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 */ private function dispatchToFcm(string $token, ?string $title, ?string $body, array $data): bool { if (!file_exists($this->serviceAccountPath)) { error_log("[NotificationService] Firebase service account file missing: {$this->serviceAccountPath}"); return false; } $message = [ 'token' => $token, 'data' => array_map('strval', $data), ]; if ($title || $body) { $message['notification'] = [ 'title' => $title, 'body' => $body, ]; $message['android'] = [ 'priority' => 'high', 'notification' => [ 'sound' => 'default', 'channel_id' => 'high_importance_channel' ] ]; $message['apns'] = [ 'payload' => [ 'aps' => [ 'sound' => 'default', ], ], ]; } else { // 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' ]; $message['apns'] = [ 'headers' => [ 'apns-priority' => '5', 'apns-push-type' => 'background' ], 'payload' => [ 'aps' => [ 'content-available' => 1 ] ] ]; } 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); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 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 [$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. */ private function getAccessToken(): ?string { // Check cache first (token is valid for 1 hour, we cache for 50 min) $cacheFile = STORAGE_PATH . '/cache/fcm_token.json'; if (file_exists($cacheFile)) { $cached = json_decode(file_get_contents($cacheFile), true); if ($cached && ($cached['expires_at'] ?? 0) > time()) { return $cached['access_token']; } } if (!file_exists($this->serviceAccountPath)) { error_log("[NotificationService] Firebase service account file missing"); return null; } $sa = json_decode(file_get_contents($this->serviceAccountPath), true); if (!$sa || empty($sa['private_key']) || empty($sa['client_email'])) { error_log("[NotificationService] Invalid service account JSON"); return null; } // Build JWT $now = time(); $header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']); $payload = json_encode([ 'iss' => $sa['client_email'], 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => 'https://oauth2.googleapis.com/token', 'iat' => $now, 'exp' => $now + 3600, ]); $b64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '='); $b64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '='); $signingInput = $b64Header . '.' . $b64Payload; $privateKey = openssl_pkey_get_private($sa['private_key']); if (!$privateKey) { error_log("[NotificationService] Failed to parse private key"); return null; } openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); $b64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); $jwt = $signingInput . '.' . $b64Signature; // Exchange JWT for access token $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]), ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { error_log("[NotificationService] Token exchange failed ($httpCode): $response"); return null; } $tokenData = json_decode($response, true); $accessToken = $tokenData['access_token'] ?? null; if ($accessToken) { // Cache for 50 minutes @file_put_contents($cacheFile, json_encode([ 'access_token' => $accessToken, 'expires_at' => $now + 3000, ])); } return $accessToken; } }