Firebase FCM Client

Pub Version License: MIT

Firebase Cloud Messaging (FCM) HTTP v1 API client for Dart with production-ready authentication.

Send push notifications to devices, topics, and conditions without managing private keys.

What's New in v0.0.3

Production-Ready Authentication

  • Works in Cloud Run, GKE, Docker, Kubernetes
  • No gcloud CLI needed in production
  • Multiple auth methods automatically detected

Enhanced Security

  • Uses googleapis_auth for secure token handling
  • Automatic token refresh before expiry
  • No credentials in code

Improved Reliability

  • Supports GOOGLE_APPLICATION_CREDENTIALS environment variable
  • Fallback authentication methods
  • Better error messages for debugging

Features

  • Send to Device Tokens - Target specific devices
  • Send to Topics - Broadcast to subscribed devices
  • Send to Conditions - Complex targeting with logical operators
  • Service Account Impersonation - Secure authentication, no private keys
  • Android Configuration - Customize Android-specific behavior
  • Web Push Configuration - Web-specific notification settings
  • Automatic Token Caching - Efficient token management
  • Automatic Token Refresh - Handles expiration transparently
  • Complete Error Handling - Detailed error messages
  • Full Type Safety - 100% Dart type-safe
  • Enterprise-Grade Security - No credentials in code
  • Production Ready - Works in all environments

Installation

Add to pubspec.yaml:

dependencies:
  firebase_fcm_client: ^0.0.3

Then run:

dart pub get

Setup (Choose One Method)

# 1. Create service account
gcloud iam service-accounts create intellitoggle-fcm \
  --display-name="IntelliToggle FCM Service"

# 2. Grant permissions
gcloud projects add-iam-policy-binding PROJECT_ID \
  --member=serviceAccount:intellitoggle-fcm@PROJECT.iam.gserviceaccount.com \
  --role=roles/firebase.cloudmessagingServiceAgent

# 3. Deploy with service account
gcloud run deploy my-backend \
  --source . \
  --service-account=intellitoggle-fcm@PROJECT.iam.gserviceaccount.com

Result: Works automatically! No env vars needed.


Method 2: Docker / Kubernetes

# 1. Create service account JSON key
gcloud iam service-accounts keys create key.json \
  --iam-account=intellitoggle-fcm@PROJECT.iam.gserviceaccount.com

# 2. Keep it safe
echo "key.json" >> .gitignore

docker-compose.yml:

environment:
  - GOOGLE_APPLICATION_CREDENTIALS=/secrets/service-account.json
volumes:
  - ./key.json:/secrets/service-account.json:ro

Kubernetes:

env:
  - name: GOOGLE_APPLICATION_CREDENTIALS
    value: /var/secrets/fcm/service-account.json
volumeMounts:
  - name: fcm-secret
    mountPath: /var/secrets/fcm

Method 3: Local Development

gcloud auth application-default login

Then run:

dart run bin/main.dart

Quick Start

Basic Example

import 'package:firebase_fcm_client/firebase_fcm_client.dart';

void main() async {
  // Works in all environments:
  // - Cloud Run (automatic)
  // - GKE (automatic)
  // - Docker (via GOOGLE_APPLICATION_CREDENTIALS)
  // - Local dev (via gcloud auth)
  final auth = await ServiceAccountAuth.fromImpersonation(
    serviceAccountEmail: 'intellitoggle-fcm@project.iam.gserviceaccount.com',
    projectId: 'intellitoggle-project',
  );

  final fcm = FCMClient(auth: auth, projectId: 'intellitoggle-project');

  try {
    // Send notification
    final result = await fcm.sendToToken(
      'device_token_here',
      title: 'Hello!',
      body: 'This is a test notification',
    );

    print('Message sent: $result');
    await auth.close();
  } catch (e) {
    print('Error: $e');
  }
}

API Reference

ServiceAccountAuth

Handles authentication for Firebase Cloud Messaging.

Initialization

final auth = await ServiceAccountAuth.fromImpersonation({
  required String serviceAccountEmail,
  required String projectId,
});

How it works (automatic):

  1. Tries googleapis_auth (works in Cloud Run, GKE, local gcloud)
  2. Tries GOOGLE_APPLICATION_CREDENTIALS env var (Docker, Kubernetes)
  3. Falls back to gcloud CLI (local dev)

Throws:

  • Exception if all auth methods fail

Methods

// Get access token (cached automatically)
Future<String> getAccessToken()

// Close resources
Future<void> close()

FCMClient

Firebase Cloud Messaging HTTP v1 API client.

Constructor

final fcm = FCMClient({
  required ServiceAccountAuth auth,
  required String projectId,
});

Send to Device Token

final result = await fcm.sendToToken(
  'device_token',
  title: 'Title',
  body: 'Body text',
  data: {
    'key1': 'value1',
    'key2': 'value2',
  },
  android: AndroidConfig(
    priority: 'high',
    notification: AndroidNotification(
      color: '#FF5733',
      sound: 'default',
    ),
  ),
  webpush: WebpushConfig(
    notification: WebpushNotification(
      title: 'Web Title',
      icon: 'https://example.com/icon.png',
    ),
  ),
);

print('Sent: $result'); // Message ID

Send to Topic

final result = await fcm.sendToTopic(
  'news',
  title: 'Breaking News',
  body: 'Important update available',
  data: {
    'story_id': '12345',
  },
);

Send to Condition

final result = await fcm.sendToCondition(
  "'sports' in topics && 'cricket' in topics",
  title: 'Cricket News',
  body: 'Latest updates',
);

Authentication Flow

┌─ serviceAccountEmail: 'fcm-sa@project.iam.gserviceaccount.com'
│
└─ ServiceAccountAuth.fromImpersonation()
   │
   ├─ Try 1: googleapis_auth + Application Default Credentials
   │  ├─ Works in: Cloud Run 
   │  ├─ Works in: GKE 
   │  ├─ Works in: Local (gcloud auth) 
   │  └─ Success → Use this
   │
   ├─ Try 2: GOOGLE_APPLICATION_CREDENTIALS environment variable
   │  ├─ Works in: Docker 
   │  ├─ Works in: Kubernetes 
   │  ├─ Requires: env var set + JSON file mounted
   │  └─ Success → Use this
   │
   ├─ Try 3: gcloud CLI (fallback)
   │  ├─ Works in: Local dev 
   │  ├─ Requires: gcloud installed + auth setup
   │  └─ Success → Use this
   │
   └─ All failed → Throw helpful error

Error Handling

try {
  await fcm.sendToToken(token, title: '...', body: '...');
} on FCMException catch (e) {
  print('Error: ${e.message}');
  print('Code: ${e.code}');
  print('Status: ${e.statusCode}');
}

Common Errors & Solutions

Error Cause Solution
All authentication methods failed No auth setup Use one of the 3 setup methods
GOOGLE_APPLICATION_CREDENTIALS not set Docker/K8s issue Set env var and mount secret
gcloud command failed gcloud CLI missing Run gcloud auth application-default login
Invalid token Device token expired Get fresh token from Firebase Messaging
Permission denied Missing IAM role Ask admin to grant proper role

Examples

Real-World

class NotificationService {
  late FCMClient _fcm;
  late ServiceAccountAuth _auth;

  Future<void> initialize() async {
    _auth = await ServiceAccountAuth.fromImpersonation(
      serviceAccountEmail: 'intellitoggle-fcm@project.iam.gserviceaccount.com',
      projectId: 'intellitoggle-project',
    );
    _fcm = FCMClient(auth: _auth, projectId: 'intellitoggle-project');
  }

  Future<void> notifyFlagToggle({
    required String userId,
    required String deviceToken,
    required String flagName,
    required bool newValue,
  }) async {
    await _fcm.sendToToken(
      deviceToken,
      title: '$flagName was toggled ${newValue ? "ON" : "OFF"}',
      body: 'Feature flag changed in production',
      data: {
        'actionType': 'feature-flag-update',
        'flagName': flagName,
        'newValue': newValue.toString(),
      },
    );
  }

  Future<void> shutdown() async {
    await _auth.close();
  }
}

Send with Custom Android Config

await fcm.sendToToken(
  deviceToken,
  title: 'Urgent Alert',
  body: 'Action required',
  android: AndroidConfig(
    priority: 'high',
    notification: AndroidNotification(
      color: '#FF0000',
      sound: 'alarm',
      priority: 2,
    ),
  ),
);

Send to Topic

await fcm.sendToTopic(
  'feature-updates',
  title: 'New Feature Available',
  body: 'Check out our latest update',
);

Best Practices

1. Reuse ServiceAccountAuth

// Create once at startup
final auth = await ServiceAccountAuth.fromImpersonation(...);

// Reuse for all FCM clients
final fcm1 = FCMClient(auth: auth, projectId: 'project1');
final fcm2 = FCMClient(auth: auth, projectId: 'project2');

// Close when shutting down
await auth.close();

2. Implement Retry Logic

Future<String> sendWithRetry(String token, String title, String body) async {
  int retries = 3;
  while (retries > 0) {
    try {
      return await fcm.sendToToken(token, title: title, body: body);
    } catch (e) {
      retries--;
      if (retries == 0) rethrow;
      await Future.delayed(Duration(seconds: 2 ^ (3 - retries)));
    }
  }
  throw Exception('Failed after retries');
}

3. Validate Tokens Before Sending

bool isValidToken(String token) {
  return token.isNotEmpty && token.length > 100;
}

if (isValidToken(deviceToken)) {
  await fcm.sendToToken(deviceToken, title: '...', body: '...');
}

4. Use Topics for Bulk Messaging

// Instead of looping through tokens
for (final token in tokens) {
  await fcm.sendToToken(token, title: '...', body: '...');
}

// Use topics (more efficient)
await fcm.sendToTopic('users', title: '...', body: '...');

Testing

Unit Tests

dart test test/firebase_fcm_notification_test.dart
//or
dart test -r expanded

Tests cover:

  • Model serialization/deserialization
  • Message construction
  • Error handling
  • Complex message scenarios

Integration Test

# Setup auth first
gcloud auth application-default login

# Run full test
dart test

Migration from v0.0.2 to v0.0.3

No breaking changes to your code! Just update the package version:

dependencies:
  firebase_fcm_client: ^0.0.3

Your existing code works as-is:

final auth = await ServiceAccountAuth.fromImpersonation(
  serviceAccountEmail: 'fcm-sa@project.iam.gserviceaccount.com',
  projectId: 'your-project-id',
);

The difference: v0.0.3 now works in production environments (Cloud Run, Docker, K8s)!


Troubleshooting

Test Authentication

# Test Cloud Run/GKE auth
gcloud auth application-default login
dart run bin/main.dart

# Should print: Using Application Default Credentials

Check Environment Variable

# Docker/Kubernetes
echo $GOOGLE_APPLICATION_CREDENTIALS  # Should print path
ls -la /path/to/service-account.json  # Should exist

Debug Logs

// Enable detailed logging
final auth = await ServiceAccountAuth.fromImpersonation(
  serviceAccountEmail: 'fcm-sa@project.iam.gserviceaccount.com',
  projectId: 'intellitoggle-project',
);

// Will print which auth method is being used
final token = await auth.getAccessToken();

Performance

  • Token Caching: Tokens cached and reused until expiration
  • Automatic Refresh: Tokens refreshed transparently before expiry
  • Connection Pooling: HTTP keep-alive enabled
  • Concurrent Requests: Multiple messages can be sent in parallel

Security

No Private Keys: Uses secure service account impersonation
Automatic Token Refresh: Credentials never exposed
Credential Isolation: No secrets in code/config
Audit Trail: All operations logged in Google Cloud


Support

  • Issues: GitHub Issues
  • Documentation: Check code comments for detailed explanations
  • Examples: See examples/ directory for complete implementations

License

MIT License - See LICENSE file for details


Changelog

See CHANGELOG.md for version history


Made with ❤️ for Dart and Flutter developers