Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## [0.2.0]

### Changed
- **Default API base URL** is now `https://api.pushfire.app/functions/v1/` (was the raw Supabase functions URL). Functionally identical endpoint; keeps the backend project ref out of the SDK and logs. Override via `PushFireConfig.baseUrl` is unaffected.
- **Clearer API error logging**: failed requests now log a single `API error: <METHOD> <endpoint> -> HTTP <status> code=<code> msg="<message>"` line instead of a misleading "Unexpected error" plus duplicate "X failed" lines across layers.

### Fixed
- **Structured API exceptions are preserved**: a non-2xx response now throws a `PushFireApiException` whose `statusCode`, `code`, and `responseBody` are populated, instead of being re-wrapped into a generic exception with `statusCode == null`.

### ⚠️ Potentially breaking
- **Request timeouts now throw `PushFireNetworkException`** with a clear "timed out after Ns" message. Previously a timeout surfaced as a generic `PushFireApiException` with an "Unexpected error" message. If you catch `PushFireApiException` specifically to handle timeouts, also catch `PushFireNetworkException` — or catch the shared base `PushFireException`. (`SocketException`/`HttpException` already mapped to `PushFireNetworkException`.)

### Internal
- `PushFireApiClient` accepts an injectable `http.Client` for testing; added API client error/timeout/network test coverage.

## [0.1.11]

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,12 +498,14 @@ if (PushFireSDK.isInitialized) {
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `apiKey` | String | Yes | - | Your PushFire API key |
| `baseUrl` | String | No | `https://jojnoebcqoqjlshwzmjm.supabase.co/functions/v1/` | API base URL |
| `baseUrl` | String | No | `https://api.pushfire.app/functions/v1/` | API base URL |
| `enableLogging` | bool | No | `false` | Enable debug logging |
| `timeoutSeconds` | int | No | `30` | Request timeout in seconds |
| `authProvider` | AuthProvider | No | `AuthProvider.none` | Authentication provider for automatic subscriber management |
| `requestNotificationPermission` | bool | No | `true` | Automatically request notification permissions during SDK initialization |

> **⚠️ Logging & privacy:** When `enableLogging` is `true`, the SDK logs full request and response bodies — including subscriber PII such as `email`, `phone`, `name`, and `metadata`. Logging is **off by default** and uses `dart:developer` (generally not emitted in release builds), but if you forward SDK logs anywhere (for example a crash reporter or log aggregator), do **not** do so in production builds with logging enabled.

## Notification Permissions

The PushFire SDK provides flexible notification permission handling to accommodate different app requirements and user experience strategies.
Expand Down
31 changes: 24 additions & 7 deletions lib/src/api/pushfire_api_client.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
Expand All @@ -10,8 +11,8 @@ class PushFireApiClient {
final PushFireConfig _config;
late final http.Client _httpClient;

PushFireApiClient(this._config) {
_httpClient = http.Client();
PushFireApiClient(this._config, {http.Client? httpClient}) {
_httpClient = httpClient ?? http.Client();
}

/// Get common headers for API requests
Expand Down Expand Up @@ -86,21 +87,30 @@ class PushFireApiClient {
response.body,
);

return _handleResponse(response);
return _handleResponse(method, endpoint, response);
} on PushFireException {
rethrow;
} on TimeoutException catch (e) {
final message =
'Request to $method $endpoint timed out after ${_config.timeoutSeconds}s';
PushFireLogger.error(message, e);
throw PushFireNetworkException(message, originalError: e);
} on SocketException catch (e) {
PushFireLogger.error('Network error: ${e.message}', e);
PushFireLogger.error(
'Network error during $method $endpoint: ${e.message}', e);
throw PushFireNetworkException(
'Network error: ${e.message}',
originalError: e,
);
} on HttpException catch (e) {
PushFireLogger.error('HTTP error: ${e.message}', e);
PushFireLogger.error(
'HTTP error during $method $endpoint: ${e.message}', e);
throw PushFireNetworkException(
'HTTP error: ${e.message}',
originalError: e,
);
} catch (e) {
PushFireLogger.error('Unexpected error during API request', e);
PushFireLogger.error('Unexpected error during $method $endpoint', e);
throw PushFireApiException(
'Unexpected error: $e',
originalError: e,
Expand All @@ -109,7 +119,11 @@ class PushFireApiClient {
}

/// Handle HTTP response
Map<String, dynamic> _handleResponse(http.Response response) {
Map<String, dynamic> _handleResponse(
String method,
String endpoint,
http.Response response,
) {
final statusCode = response.statusCode;
final body = response.body;

Expand Down Expand Up @@ -142,6 +156,9 @@ class PushFireApiClient {
errorMessage = 'API request failed with status $statusCode';
}

PushFireLogger.logApiError(
method, endpoint, statusCode, errorCode, errorMessage);

throw PushFireApiException(
errorMessage,
code: errorCode,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/config/pushfire_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class PushFireConfig {

const PushFireConfig({
required this.apiKey,
this.baseUrl = 'https://jojnoebcqoqjlshwzmjm.supabase.co/functions/v1/',
this.baseUrl = 'https://api.pushfire.app/functions/v1/',
this.enableLogging = false,
this.timeoutSeconds = 30,
this.authProvider = AuthProvider.none,
Expand Down
133 changes: 56 additions & 77 deletions lib/src/pushfire_sdk_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,10 @@ class PushFireSDKImpl with WidgetsBindingObserver {
await Firebase.initializeApp();
PushFireLogger.info('Firebase initialized successfully');
} catch (e) {
// Firebase might already be initialized, which is fine
// Firebase is most likely already initialized (common and harmless).
// If it is a real failure, downstream FCM calls will surface it.
PushFireLogger.info(
'Firebase already initialized or initialization failed: $e');
'Firebase initializeApp() skipped or failed (continuing): $e');
}

// Initialize services
Expand All @@ -137,9 +138,9 @@ class PushFireSDKImpl with WidgetsBindingObserver {
case AuthProvider.supabase:
PushFireLogger.info('Listening for auth state changes');
// Listen for auth state changes
_supabaseAuthSubscription =
sp.Supabase.instance.client.auth.onAuthStateChange
.listen((data) async {
_supabaseAuthSubscription = sp
.Supabase.instance.client.auth.onAuthStateChange
.listen((data) async {
final event = data.event;
final session = data.session;
// Handle auth state changes if needed
Expand All @@ -150,8 +151,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {
final user = session.user;

// Skip if already logged in as this user
final current =
await _subscriberService.getCurrentSubscriber();
final current = await _subscriberService.getCurrentSubscriber();
if (current != null && current.externalId == user.id) {
PushFireLogger.info(
'Subscriber already logged in as ${user.id}, skipping auto-login');
Expand All @@ -167,10 +167,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {
? null
: user.userMetadata?['full_name'];
loginSubscriber(
externalId: user.id,
email: email,
phone: phone,
name: name);
externalId: user.id, email: email, phone: phone, name: name);
} else if (event == sp.AuthChangeEvent.signedOut) {
logoutSubscriber();
}
Expand All @@ -184,8 +181,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {
PushFireLogger.info('User signed in: ${user.uid}');

// Skip if already logged in as this user
final current =
await _subscriberService.getCurrentSubscriber();
final current = await _subscriberService.getCurrentSubscriber();
if (current != null && current.externalId == user.uid) {
PushFireLogger.info(
'Subscriber already logged in as ${user.uid}, skipping auto-login');
Expand All @@ -201,10 +197,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {
? null
: user.phoneNumber;
loginSubscriber(
externalId: user.uid,
email: email,
name: name,
phone: phone);
externalId: user.uid, email: email, name: name, phone: phone);
} else {
PushFireLogger.info('User signed out');
logoutSubscriber();
Expand All @@ -226,7 +219,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {
_deviceRegisteredController.add(_currentDevice!);
PushFireLogger.info('Device auto-registration completed');
} catch (e) {
PushFireLogger.error('Device auto-registration failed', e);
PushFireLogger.warning('Device auto-registration failed', e);
// Don't throw here - allow SDK to continue working
}
}
Expand All @@ -249,7 +242,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {

PushFireLogger.info('Device updated with new FCM token');
} catch (e) {
PushFireLogger.error('Failed to update device with new FCM token', e);
PushFireLogger.warning('Failed to update device with new FCM token', e);
}
});
}
Expand Down Expand Up @@ -301,7 +294,7 @@ class PushFireSDKImpl with WidgetsBindingObserver {
PushFireLogger.info('Device updated after permission status change');
}
} catch (e) {
PushFireLogger.error(
PushFireLogger.warning(
'Failed to check permission status on app resume', e);
} finally {
_isCheckingPermission = false;
Expand All @@ -318,21 +311,16 @@ class PushFireSDKImpl with WidgetsBindingObserver {
}) async {
_ensureInitialized();

try {
_currentSubscriber = await _subscriberService.loginSubscriber(
externalId: externalId,
name: name,
email: email,
phone: phone,
metadata: metadata,
);
_currentSubscriber = await _subscriberService.loginSubscriber(
externalId: externalId,
name: name,
email: email,
phone: phone,
metadata: metadata,
);

_subscriberLoggedInController.add(_currentSubscriber!);
return _currentSubscriber!;
} catch (e) {
PushFireLogger.error('Subscriber login failed', e);
rethrow;
}
_subscriberLoggedInController.add(_currentSubscriber!);
return _currentSubscriber!;
}

/// Update subscriber
Expand All @@ -349,33 +337,28 @@ class PushFireSDKImpl with WidgetsBindingObserver {
throw const PushFireSubscriberException('No subscriber logged in');
}

try {
// Always use current subscriber's externalId (backend doesn't allow updates)
await _subscriberService.updateSubscriber(
subscriberId: currentSubscriber!.id!,
externalId: currentSubscriber.externalId,
name: name,
email: email,
phone: phone,
metadata: metadata,
);
// Always use current subscriber's externalId (backend doesn't allow updates)
await _subscriberService.updateSubscriber(
subscriberId: currentSubscriber!.id!,
externalId: currentSubscriber.externalId,
name: name,
email: email,
phone: phone,
metadata: metadata,
);

// Update the local subscriber state (externalId remains unchanged)
_currentSubscriber = currentSubscriber.copyWith(
name: name,
email: email,
phone: phone,
metadata: metadata,
);
// Update the local subscriber state (externalId remains unchanged)
_currentSubscriber = currentSubscriber.copyWith(
name: name,
email: email,
phone: phone,
metadata: metadata,
);

// Store updated subscriber data
await _subscriberService.storeSubscriberData(_currentSubscriber!);
// Store updated subscriber data
await _subscriberService.storeSubscriberData(_currentSubscriber!);

return _currentSubscriber!;
} catch (e) {
PushFireLogger.error('Subscriber update failed', e);
rethrow;
}
return _currentSubscriber!;
}

/// Logout subscriber
Expand All @@ -384,11 +367,12 @@ class PushFireSDKImpl with WidgetsBindingObserver {

try {
await _subscriberService.logoutSubscriber();
} finally {
// SubscriberService clears persisted local data even when the logout
// API call fails, so always mirror that cleared session in memory and
// emit the logout event. Any error still propagates to the caller.
_currentSubscriber = null;
_subscriberLoggedOutController.add(null);
} catch (e) {
PushFireLogger.error('Subscriber logout failed', e);
rethrow;
}
}

Expand Down Expand Up @@ -576,26 +560,21 @@ class PushFireSDKImpl with WidgetsBindingObserver {
Future<void> reset() async {
_ensureInitialized();

try {
PushFireLogger.info('Resetting SDK');
PushFireLogger.info('Resetting SDK');

// Logout subscriber if logged in
if (await isSubscriberLoggedIn()) {
await logoutSubscriber();
}
// Logout subscriber if logged in
if (await isSubscriberLoggedIn()) {
await logoutSubscriber();
}

// Clear device data
await _deviceService.clearDeviceData();
// Clear device data
await _deviceService.clearDeviceData();

// Reset current state
_currentDevice = null;
_currentSubscriber = null;
// Reset current state
_currentDevice = null;
_currentSubscriber = null;

PushFireLogger.info('SDK reset completed');
} catch (e) {
PushFireLogger.error('SDK reset failed', e);
rethrow;
}
PushFireLogger.info('SDK reset completed');
}

/// Dispose SDK resources
Expand Down
Loading
Loading