Skip to content
Open
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
4 changes: 3 additions & 1 deletion lib/core/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ class AppConfig {
}

/// Get the API base URL
@Deprecated('FDK uses WebSocket-only communication. '
'Use EnvironmentConfig.webSocketUrl instead.')
static String get apiBaseUrl {
// This is deprecated - use EnvironmentConfig.apiBaseUrl instead
// ignore: deprecated_member_use_from_same_package
return EnvironmentConfig.apiBaseUrl;
}

Expand Down
29 changes: 28 additions & 1 deletion lib/core/config/environment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class EnvironmentConfig {

static void setEnvironment(Environment env) {
_environment = env;
warnIfCompileTimeCredentials();
// Only log in debug mode to avoid memory issues
if (kDebugMode) {
debugPrint(
Expand All @@ -27,7 +28,10 @@ class EnvironmentConfig {
static bool get isStaging => _environment == Environment.staging;
static bool get isProduction => _environment == Environment.production;

/// API Configuration
/// REST API base URL - currently unused as the app uses WebSocket-only
/// communication. Retained for potential future REST endpoint needs.
@Deprecated('FDK uses WebSocket-only communication. '
'Use webSocketUrl instead for all data operations.')
static String get apiBaseUrl {
switch (_environment) {
case Environment.development:
Expand Down Expand Up @@ -124,6 +128,11 @@ class EnvironmentConfig {

/// API Credentials
///
/// SECURITY NOTE: Values provided via --dart-define are compiled as string
/// constants into the binary and can be extracted from APK/IPA files.
/// For production builds, use runtime credential injection (QR code scanning
/// or manual entry) instead of compile-time constants.
///
/// For staging/production, credentials MUST be provided via environment variables:
/// - STAGING_API_LOGIN / API_USERNAME
/// - STAGING_API_KEY or STAGING_TOKEN / API_KEY or WS_TOKEN
Expand Down Expand Up @@ -210,6 +219,24 @@ class EnvironmentConfig {
}
}

/// Checks if compile-time credentials are present and logs a security
/// warning if they are being used in a release build.
/// Call this during app initialization to alert developers.
static void warnIfCompileTimeCredentials() {
const apiKey = String.fromEnvironment('API_KEY', defaultValue: '');
const wsToken = String.fromEnvironment('WS_TOKEN', defaultValue: '');
const apiUsername = String.fromEnvironment('API_USERNAME', defaultValue: '');

if (!kDebugMode && (apiKey.isNotEmpty || wsToken.isNotEmpty || apiUsername.isNotEmpty)) {
debugPrint(
'SECURITY WARNING: Compile-time credentials detected in a non-debug build. '
'Values passed via --dart-define are embedded as string constants in the binary '
'and can be extracted from APK/IPA files. For production, use runtime credential '
'injection (QR code scanning or manual entry) instead.',
);
}
}

/// Sentry DSN
static String get sentryDsn {
if (isDevelopment) {
Expand Down
23 changes: 19 additions & 4 deletions lib/core/security/certificate_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,27 @@ class CertificateValidator {
return false;
}

// Accept valid certificates (matches ATT-FE-Tool behavior)
LoggerService.debug(
'Accepting non-self-signed certificate for $host:$port',
// This callback is only invoked when the platform's default validation
// has already FAILED. Accepting here would bypass chain-of-trust checks
// (wrong hostname, untrusted CA, revoked cert) and enable MITM attacks.
if (kDebugMode) {
LoggerService.warning(
'DEBUG MODE: Accepting platform-rejected certificate for $host:$port. '
'This certificate failed platform validation (possible untrusted CA, '
'hostname mismatch, or revocation). Only accepted because debug mode is active.',
tag: 'CertificateValidator',
);
return true; // Accept in debug only for local development
}

LoggerService.error(
'CERTIFICATE REJECTED: $host:$port. '
'The certificate failed platform TLS validation (untrusted CA, '
'hostname mismatch, or revoked). Rejecting to prevent potential '
'man-in-the-middle attacks.',
tag: 'CertificateValidator',
);
return true;
return false; // REJECT in production - do not bypass platform validation
} catch (e, stack) {
LoggerService.error(
'Certificate validation error for $host:$port',
Expand Down
14 changes: 14 additions & 0 deletions lib/core/services/websocket_channel_factory_web.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import 'package:rgnets_fdk/core/services/logger_service.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

/// Creates a WebSocket channel for the web platform.
///
/// Note: Browser WebSocket APIs do not support custom headers.
/// The [headers] parameter (including Authorization) is ignored on web.
/// Authentication must rely on query parameters (e.g., api_key) instead.
WebSocketChannel createWebSocketChannel(
Uri uri, {
Map<String, dynamic>? headers,
}) {
if (headers != null && headers.isNotEmpty) {
LoggerService.warning(
'WebSocket headers are not supported on web platform. '
'Authorization header will not be sent. '
'Ensure api_key query parameter is included for authentication.',
tag: 'WebSocketChannelFactory',
);
}
return WebSocketChannel.connect(uri);
}
105 changes: 59 additions & 46 deletions lib/core/services/websocket_data_sync_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import 'package:rgnets_fdk/features/devices/data/models/room_model.dart';
import 'package:rgnets_fdk/features/onboarding/data/models/onboarding_status_payload.dart';
import 'package:rgnets_fdk/features/rooms/data/datasources/room_local_data_source.dart';

// ARCHITECTURE NOTE (M1): This service imports from features/ layer, violating
// Clean Architecture (core should not depend on features). This is a known
// pattern across the codebase (16 core files import from features). Moving this
// service alone would create inconsistency. A holistic architecture refactoring
// using dependency inversion (abstract interfaces in core, implementations in
// features) is the recommended approach for a future dedicated sprint.
class WebSocketDataSyncService {
WebSocketDataSyncService({
required WebSocketService socketService,
Expand Down Expand Up @@ -102,7 +108,8 @@ class WebSocketDataSyncService {
_wlanLocalDataSource.dispose();
}

Future<void> syncInitialData({
/// Returns true if sync completed successfully, false if it timed out.
Future<bool> syncInitialData({
Duration timeout = const Duration(seconds: 45),
}) async {
await start();
Expand All @@ -115,15 +122,19 @@ class WebSocketDataSyncService {
_initialSyncCompleter = Completer<void>();
_requestSnapshots();

var timedOut = false;
await _initialSyncCompleter!.future.timeout(
timeout,
onTimeout: () {
_logger.w('WebSocketDataSync: Initial sync timed out');
return;
_logger.w(
'WebSocketDataSync: Initial sync timed out after ${timeout.inSeconds}s. '
'Pending: $_pendingSnapshots',
);
timedOut = true;
},
);

// Flush all typed caches to storage
// Flush all typed caches to storage (may be partial on timeout)
await _flushAllDeviceCaches();

// Wait for any pending room cache operations
Expand All @@ -133,10 +144,14 @@ class WebSocketDataSyncService {
const Duration(seconds: 30),
onTimeout: () {
_logger.w('WebSocketDataSync: Room cache timed out');
timedOut = true;
},
);
}
_logger.i('WebSocketDataSync: Cache operations completed');
_logger.i(
'WebSocketDataSync: Cache operations completed${timedOut ? ' (partial - timed out)' : ''}',
);
return !timedOut;
}

/// Flush all typed device caches to storage
Expand Down Expand Up @@ -277,22 +292,14 @@ class WebSocketDataSyncService {

List<Map<String, dynamic>>? _extractSnapshotItems(SocketMessage message) {
final payload = message.payload;
if (payload['results'] is List) {
return (payload['results'] as List)
.whereType<Map<String, dynamic>>()
.toList();
}
// Backend (RxgWebsocketCrudService) uses 'data' key for response arrays
if (payload['data'] is List) {
return (payload['data'] as List)
.whereType<Map<String, dynamic>>()
.toList();
}
if (payload['items'] is List) {
return (payload['items'] as List)
.whereType<Map<String, dynamic>>()
.toList();
}
if (payload['results'] is List<dynamic>) {
// Fallback for legacy or alternative response formats
if (payload['results'] is List) {
return (payload['results'] as List)
.whereType<Map<String, dynamic>>()
.toList();
Expand Down Expand Up @@ -370,7 +377,9 @@ class WebSocketDataSyncService {
}
}
// Always cache to clear stale data when snapshot is empty
unawaited(_apLocalDataSource.cacheDevices(models));
unawaited(_apLocalDataSource.cacheDevices(models).catchError((Object e) {
_logger.e('WebSocketDataSync: Failed to cache APs: $e');
}));
_logger.d('WebSocketDataSync: Cached ${models.length} APs');
if (models.isNotEmpty) {
_emitDevicesCached(models.length);
Expand All @@ -389,7 +398,9 @@ class WebSocketDataSyncService {
}
}
// Always cache to clear stale data when snapshot is empty
unawaited(_ontLocalDataSource.cacheDevices(models));
unawaited(_ontLocalDataSource.cacheDevices(models).catchError((Object e) {
_logger.e('WebSocketDataSync: Failed to cache ONTs: $e');
}));
_logger.d('WebSocketDataSync: Cached ${models.length} ONTs');
if (models.isNotEmpty) {
_emitDevicesCached(models.length);
Expand All @@ -408,7 +419,9 @@ class WebSocketDataSyncService {
}
}
// Always cache to clear stale data when snapshot is empty
unawaited(_switchLocalDataSource.cacheDevices(models));
unawaited(_switchLocalDataSource.cacheDevices(models).catchError((Object e) {
_logger.e('WebSocketDataSync: Failed to cache Switches: $e');
}));
_logger.d('WebSocketDataSync: Cached ${models.length} Switches');
if (models.isNotEmpty) {
_emitDevicesCached(models.length);
Expand All @@ -427,7 +440,9 @@ class WebSocketDataSyncService {
}
}
// Always cache to clear stale data when snapshot is empty
unawaited(_wlanLocalDataSource.cacheDevices(models));
unawaited(_wlanLocalDataSource.cacheDevices(models).catchError((Object e) {
_logger.e('WebSocketDataSync: Failed to cache WLANs: $e');
}));
_logger.d('WebSocketDataSync: Cached ${models.length} WLANs');
if (models.isNotEmpty) {
_emitDevicesCached(models.length);
Expand Down Expand Up @@ -460,8 +475,7 @@ class WebSocketDataSyncService {
_roomSnapshots
..clear()
..['rooms.summary'] = models;
_pendingRoomCache = _cacheRooms(models);
unawaited(_pendingRoomCache);
_chainRoomCache(models);
return;
}

Expand All @@ -471,11 +485,20 @@ class WebSocketDataSyncService {
for (final entry in _roomResources) {
combined.addAll(_roomSnapshots[entry] ?? const []);
}
_pendingRoomCache = _cacheRooms(combined);
unawaited(_pendingRoomCache);
_chainRoomCache(combined);
}
}

/// Chains a new room cache operation onto any pending one, preventing
/// the race condition where _pendingRoomCache gets overwritten while
/// syncInitialData is awaiting the previous future.
void _chainRoomCache(List<RoomModel> rooms) {
final previous = _pendingRoomCache ?? Future<void>.value();
_pendingRoomCache = previous.then((_) => _cacheRooms(rooms)).catchError((Object e) {
_logger.e('WebSocketDataSync: Failed to cache rooms: $e');
});
}

Future<void> _cacheRooms(List<RoomModel> rooms) async {
_logger.i('WebSocketDataSync: Caching ${rooms.length} rooms');
await _roomLocalDataSource.cacheRooms(rooms);
Expand Down Expand Up @@ -607,25 +630,22 @@ class WebSocketDataSyncService {
}

String _determineStatus(Map<String, dynamic> device) {
// Backend uses 'online' boolean field (AccessPoint.online)
final onlineFlag = device['online'] as bool?;
final activeFlag = device['active'] as bool?;

if (onlineFlag != null) {
return onlineFlag ? 'online' : 'offline';
}
if (device['status']?.toString().toLowerCase() == 'online') {
return 'online';
}
if (device['status']?.toString().toLowerCase() == 'offline') {
return 'offline';
}
if (activeFlag != null) {
return activeFlag ? 'online' : 'offline';

// Fallback: check string 'status' field
final statusStr = device['status']?.toString().toLowerCase();
if (statusStr == 'online' || statusStr == 'offline') {
return statusStr!;
}

if (device['last_seen'] != null || device['updated_at'] != null) {
// Derive status from last_seen_at/last_seen timestamp (produces 'warning')
if (device['last_seen_at'] != null || device['last_seen'] != null || device['updated_at'] != null) {
try {
final lastSeenStr = (device['last_seen'] ?? device['updated_at'])
final lastSeenStr = (device['last_seen_at'] ?? device['last_seen'] ?? device['updated_at'])
.toString();
final lastSeen = DateTime.parse(lastSeenStr);
final now = DateTime.now();
Expand Down Expand Up @@ -692,17 +712,10 @@ class WebSocketDataSyncService {
}

List<String>? _extractImages(Map<String, dynamic> deviceMap) {
final imageKeys = [
// Backend uses 'images' key (has_many_base64_attached :images)
const imageKeys = [
'images',
'image',
'image_url',
'imageUrl',
'photos',
'photo',
'photo_url',
'photoUrl',
'device_images',
'device_image',
'image', // Fallback for singular image field
];

for (final key in imageKeys) {
Expand Down
Loading