Hi,
I have problem with my flutter app. When I am developing it or when I test it here in my country, I have no problems, because we have great internet.
When I send app to my customer in Syria, he has many problems with connection, app disconnects from server and is unable to connect back.
I am sending my two modules, connection_service.dart and socket_service.dart. Please, can you see something that can cause this problem? Also, there was huge problem to reconnect on Samsung A06, other devices are kinda better.
connection_service.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart';
import 'package:logger/logger.dart';
import 'package:hopin_taxi_customer_app/app.dart';
import 'package:hopin_taxi_customer_app/services/toast_message_service.dart';
class ConnectionService {
// Singleton
static final ConnectionService _instance = ConnectionService._internal();
factory ConnectionService() => _instance;
ConnectionService._internal();
final _internetChecker = InternetConnection();
final _logger = Logger();
// Stream controller for connection status
final _connectionStatusController =
StreamController<ConnectionStatus>.broadcast();
// Stream that other parts of the app can listen to
Stream<ConnectionStatus> get connectionStatus =>
_connectionStatusController.stream;
// Current connection status
ConnectionStatus? _currentStatus;
ConnectionStatus? get currentStatus => _currentStatus;
bool isInitialized = false;
StreamSubscription<InternetStatus>? _internetStatusSubscription;
Future<void> init() async {
try {
// Get initial connection status
await _updateConnectionStatus();
// Set up internet connectivity monitoring
_setupInternetStatusMonitoring();
} catch (e) {
_logger.e('Error initializing ConnectionService: $e');
}
}
void _setupInternetStatusMonitoring() {
// Listen to real-time internet connectivity changes
_internetStatusSubscription = _internetChecker.onStatusChange.listen(
(InternetStatus status) async {
await _updateConnectionStatus();
},
onError: (error) {
_logger.e('Error in internet status monitoring: $error');
},
);
}
Future<void> _updateConnectionStatus() async {
try {
// Check internet access
final hasInternet = await _internetChecker.hasInternetAccess;
final newStatus = ConnectionStatus(
isConnected: hasInternet,
type: hasInternet ? 'connected' : 'disconnected',
details: {
'hasInternet': hasInternet,
'timestamp': DateTime.now().toIso8601String(),
},
);
_updateStatusIfChanged(newStatus);
} catch (e) {
_logger.e('Error updating connection status: $e');
}
}
void _updateStatusIfChanged(ConnectionStatus newStatus) {
if (_currentStatus != newStatus) {
_currentStatus = newStatus;
_connectionStatusController.add(newStatus);
// Show toast message for connection status change
// _showConnectionToast(newStatus);
}
}
void _showConnectionToast(ConnectionStatus status) {
if (navigatorKey.currentContext == null) return;
if (status.isConnected) {
if (!isInitialized) return;
ToastMessageService.showSuccess(
context: navigatorKey.currentContext!,
translationKey: "internetConnectionRestored",
descriptionKey: "internetConnectionRestoredDescription",
duration: const Duration(seconds: 5),
// forceShow: true,
alignment: Alignment.topCenter,
);
} else {
ToastMessageService.showWarning(
context: navigatorKey.currentContext!,
translationKey: "internetConnectionLost",
descriptionKey: "internetConnectionLostDescription",
duration: const Duration(seconds: 8),
// forceShow: true,
alignment: Alignment.topCenter,
);
}
isInitialized = true;
}
// Method to manually check internet connectivity
Future<bool> checkInternetConnectivity() async {
try {
return await _internetChecker.hasInternetAccess;
} catch (e) {
_logger.e('Error checking internet connectivity: $e');
return false;
}
}
// Method to get detailed connection info
Future<Map<String, dynamic>> getDetailedConnectionInfo() async {
try {
final hasInternet = await _internetChecker.hasInternetAccess;
return {
'hasInternet': hasInternet,
'timestamp': DateTime.now().toIso8601String(),
};
} catch (e) {
_logger.e('Error getting detailed connection info: $e');
return {
'hasInternet': false,
'timestamp': DateTime.now().toIso8601String(),
};
}
}
// Method to check if user has connectivity but no internet (useful for showing specific messages)
bool get hasConnectivityButNoInternet {
if (_currentStatus == null) return false;
final hasInternet = _currentStatus!.details['hasInternet'] ?? false;
return !hasInternet;
}
void dispose() {
_connectionStatusController.close();
_internetStatusSubscription?.cancel();
}
}
class ConnectionStatus {
final bool isConnected;
final String type;
final Map<String, dynamic> details;
ConnectionStatus({
required this.isConnected,
required this.type,
required this.details,
});
@override
String toString() {
return 'ConnectionStatus(isConnected: $isConnected, type: $type, details: $details)';
}
}
socket_service.dart
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:hopin_taxi_customer_app/app.dart';
import 'package:hopin_taxi_customer_app/extensions/translation_extension.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:async';
import 'dart:convert';
import 'package:hopin_taxi_customer_app/services/connection_service.dart';
import 'package:hopin_taxi_customer_app/utils/general_utils.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:hopin_taxi_customer_app/services/socket/socket_api_functions.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:hopin_taxi_customer_app/services/api/api_service.dart';
class SocketService extends ChangeNotifier {
static final SocketService _instance = SocketService._internal();
SocketService._internal();
static SocketService get instance => _instance;
String? _socketUrl;
WebSocketChannel? _channel;
StreamSubscription? _messageSubscription;
int? _userId;
String? _userKey;
bool _isSocketConnected = false;
bool _isConnectionEstablished = false;
// Reconnection logic
int _reconnectAttempts = 0;
Timer? _reconnectTimer;
final int _reconnectDelay = 2;
// bool simulateConnectionError = false;
int? lastMessageTimestamp;
int? lastConnectionAttemptTimestamp;
// Cleanup timer for old pending messages
Timer? _cleanupTimer;
bool get isSocketConnected => _isSocketConnected;
bool get isConnectionEstablished => _isConnectionEstablished;
bool setOrderStatusLoading = false;
bool get isSetOrderStatusLoading => setOrderStatusLoading;
void setIsConnectionEstablished(bool value) {
_isConnectionEstablished = value;
notifyListeners();
}
void Function(String? errorCode)? onConnectionErrorCallback;
void Function()? onConnectionRestoredCallback;
// Map to store pending completers for request-response pattern
final Map<String, Completer<Map<String, dynamic>>> _pendingMessages = {};
final Map<String, Timer> _pendingMessagesTimeout = {};
final Map<String, Function(Map<String, dynamic>)> _boundMessages = {};
void Function()? onNoAuth;
StreamSubscription<ConnectionStatus>? _connectionSubscription;
Future<Map<String, dynamic>> init() async {
Completer<Map<String, dynamic>> completer =
Completer<Map<String, dynamic>>();
initSocketURL();
try {
final prefs = await SharedPreferences.getInstance();
_userId = prefs.getInt('userId') ?? 0;
_userKey = prefs.getString('userKey') ?? '';
// Start listening to connection status
_startListeningToConnectionStatus();
Map<String, dynamic> mainConnection = await initConnection();
completer.complete(mainConnection);
} catch (e) {
completer.complete({'status': 0, 'reasonText': 'init error: $e'});
}
return completer.future;
}
void _startListeningToConnectionStatus() {
try {
_connectionSubscription?.cancel();
final connectionService = ConnectionService();
// If already connected to internet and socket not connected, try connect
final isInternet = connectionService.currentStatus?.isConnected ?? false;
if (isInternet && !_isSocketConnected) {
_tryReconnect();
}
_connectionSubscription = connectionService.connectionStatus.listen(
(status) {
// When internet is restored, try to reconnect
if (status.isConnected && !_isSocketConnected) {
_tryReconnect();
}
},
onError: (e) {
debugPrint('Sockets connection status stream error: $e');
},
);
} catch (e) {
debugPrint('Sockets _startListeningToConnectionStatus error: $e');
}
}
void initSocketURL() {
try {
final isIOS = Platform.isIOS;
final bool isTesting =
!const bool.fromEnvironment('DART_VM_PRODUCT', defaultValue: false);
_socketUrl =
!isTesting
? isIOS
? dotenv.env['WS_PROD_IOS_URL']
: dotenv.env['WS_PROD_URL'] ??
'wss://a.ws.taxi.yallago.net:443'
: dotenv.env['WS_TEST_URL'] ??
'wss://test.a.ws.taxi.hopintaxi.com:13006';
} catch (e) {
debugPrint('Sockets initSocketURL error: $e');
// Fallback URLs
_socketUrl = 'wss://a.ws.taxi.yallago.net:443';
}
}
Future<Map<String, dynamic>> initConnection() async {
Completer<Map<String, dynamic>> completer =
Completer<Map<String, dynamic>>();
try {
await disconnect();
if (_userId == 0 || _userKey == null) {
debugPrint('Sockets error: userId or userKey is null');
return {'status': 0, 'reasonText': 'userId or userKey is null'};
}
if (_socketUrl == null) {
return {'status': 0, 'reasonText': 'socketUrl is null'};
}
// Construct URL manually to avoid encoding the token
String tokenValue = '$_userId|||$_userKey';
String fullUrl = '$_socketUrl?token=$tokenValue';
try {
setIsConnectionEstablished(false);
lastConnectionAttemptTimestamp = DateTime.now().millisecondsSinceEpoch;
_channel = WebSocketChannel.connect(Uri.parse(fullUrl));
_messageSubscription = _channel!.stream.listen(
(data) {
_handleMessage(data);
},
onError: (error) {
debugPrint('Sockets onError WebSocket error: $error');
_isSocketConnected = false;
_cleanupTimer?.cancel();
notifyListeners();
_tryReconnect();
},
onDone: () async {
debugPrint('Sockets onDone WebSocket connection closed');
_isSocketConnected = false;
_cleanupTimer?.cancel();
notifyListeners();
_tryReconnect();
},
);
// Wait for connection to be established
await Future.delayed(const Duration(milliseconds: 500));
_reconnectAttempts = 0;
_reconnectTimer?.cancel();
_isSocketConnected = true;
notifyListeners();
// Start cleanup timer for old pending messages
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
_cleanupOldPendingMessages();
});
final pingResponse = await _checkConnectionPing();
if (pingResponse) {
final result = await _sendInitialDriverBeat();
setIsConnectionEstablished(true);
completer.complete(result);
} else {
completer.complete({
'pData': {'status': 0, 'reasonText': 'initConnection error'},
});
}
return completer.future;
} catch (e) {
debugPrint('Sockets WebSocketChannel connection failed: $e');
_isSocketConnected = false;
return {
'status': 0,
'reasonText': 'WebSocketChannel connection error: $e',
};
}
} catch (e) {
debugPrint('Sockets connection failed: $e');
_isSocketConnected = false;
return {'status': 0, 'reasonText': 'initConnection error: $e'};
}
}
Future<bool> _checkConnectionPing() async {
await sendMessage(pName: 'Ping', pData: {}, timeout: 4);
return true;
}
Future<Map<String, dynamic>> _sendInitialDriverBeat() async {
// Send initial DRIVER BEAT
final beatResponse = await sendDriverBeat();
if (beatResponse['pData']['status'] == 1) {
return beatResponse['pData'];
} else {
return {
'pData': {'status': 0, 'reasonText': 'initConnection error'},
};
}
}
void setSetOrderStatusLoading(bool value) {
setOrderStatusLoading = value;
notifyListeners();
}
void _handleMessage(dynamic data) {
lastMessageTimestamp = DateTime.now().millisecondsSinceEpoch;
setIsConnectionEstablished(true);
try {
// Validate input data
if (data == null) {
debugPrint('Sockets handleMessage: Received null data');
return;
}
String dataString = data.toString().trim();
if (dataString.isEmpty) {
debugPrint('Sockets handleMessage: Received empty data');
return;
}
// Try to decode JSON with better error handling
Map<String, dynamic> message;
try {
message = jsonDecode(dataString);
} catch (jsonError) {
debugPrint('Sockets handleMessage: Invalid JSON data: $dataString');
debugPrint('Sockets handleMessage: JSON decode error: $jsonError');
return;
}
// Validate message structure
if (message.isEmpty) {
debugPrint('Sockets handleMessage: Empty message object');
return;
}
// Handle server-initiated messages (pType == 1)
if (message.containsKey('pType') && message['pType'] == 1) {
String? pName = message['pName']?.toString();
String? pId = message['pId']?.toString();
if (pName != null && pName.isNotEmpty) {
// Call bound message function if exists
if (_boundMessages[pName] != null) {
try {
_boundMessages[pName]!.call(message);
} catch (e, stackTrace) {
debugPrint(
'Sockets bound message function error for $pName $e $stackTrace',
);
}
}
// Send acknowledgment message
try {
_sendAcknowledgment(pName, pId);
} catch (e, stackTrace) {
debugPrint(
'Sockets acknowledgment error for $pName: $e $stackTrace',
);
}
} else {
debugPrint(
'Sockets handleMessage: Missing or empty pName for server message',
);
}
return;
}
// Check if this is a response to a pending message
String? pName = message['pName']?.toString();
dynamic pId = message['pId'];
if (pName != null && pName.isNotEmpty && pId != null) {
// Ensure pId is converted to string safely
String pIdString = pId.toString();
String responseKey = '$pName-$pIdString';
_cancelPendingMessageTimeout(responseKey);
if (_pendingMessages.containsKey(responseKey)) {
_completeAndRemovePendingMessage(responseKey, message);
return;
}
// If we have a pType: 2 message but no matching pending message,
// try to find a pending message by name only (in case pId doesn't match)
if (message.containsKey('pType') && message['pType'] == 2) {
// Look for any pending message with the same name
String? matchingKey;
for (String key in _pendingMessages.keys) {
if (key.startsWith('$pName-')) {
matchingKey = key;
break;
}
}
if (matchingKey != null) {
if (message['pData']['status'] == 0) {
final reason =
message['pData']['reason'] != null &&
message['pData']['reason'].isNotEmpty
? message['pData']['reason']
: null;
final reasonText =
message['pData']['reasonText'] != null &&
message['pData']['reasonText'].isNotEmpty
? message['pData']['reasonText']
: null;
if (reason == null) {
message['pData']['reason'] =
reasonText ?? 'Something went wrong. Try again please.';
}
if (reasonText == null) {
message['pData']['reasonText'] =
reason ?? 'Something went wrong. Try again please.';
}
}
_completeAndRemovePendingMessage(matchingKey, message);
return;
}
}
}
// Check if this message has a bound handler (even if it's a response)
if (pName != null &&
pName.isNotEmpty &&
_boundMessages.containsKey(pName)) {
try {
_boundMessages[pName]!.call(message);
} catch (e, stackTrace) {
debugPrint(
'Sockets bound message function error for $pName: $e $stackTrace',
);
}
}
} catch (e, stackTrace) {
debugPrint('Sockets handleMessage error: $e');
debugPrint('Sockets handleMessage stack trace: $stackTrace');
debugPrint('Sockets handleMessage raw data: $data');
}
}
Future<Map<String, dynamic>> sendMessage({
Map<String, dynamic> pData = const {},
String pName = '',
int? pType,
String? pId,
int timeout = 6,
}) {
Completer<Map<String, dynamic>> completer =
Completer<Map<String, dynamic>>();
// Validate input parameters
if (pName.isEmpty) {
completer.complete({'error': 'Message name cannot be empty'});
return completer.future;
}
if (_channel != null && _isSocketConnected) {
Map<String, dynamic> payload = buildDefaultPayloadData(
pName,
pData,
pType: pType,
pId: pId,
);
pId ??= createPid();
payload['pId'] = pId;
String messageKey = '$pName-$pId';
// Track socket message sent
// final trackingService = NetworkTrackingService();
// trackingService.trackSocketMessageSent(
// messageName: pName,
// messageData: payload,
// );
// Store the completer for this message
_pendingMessages[messageKey] = completer;
_setPendingMessageTimeout(messageKey, timeout);
String jsonMessage;
try {
jsonMessage = jsonEncode(payload);
} catch (e) {
debugPrint('Sockets sendMessage jsonEncode error: $e');
// Track socket error
// trackingService.trackSocketError(
// messageName: pName,
// error: 'JSON encode error: $e',
// );
_completeAndRemovePendingMessage(messageKey, {
'pData': {'status': 0, 'reasonText': 'Failed to encode message: $e'},
});
return completer.future;
}
try {
_channel!.sink.add(jsonMessage);
} catch (e) {
debugPrint('Sockets sendMessage error: $e');
// Track socket error
// trackingService.trackSocketError(
// messageName: pName,
// error: 'Send error: $e',
// );
_isSocketConnected = false;
_completeAndRemovePendingMessage(messageKey, {
'pData': {'status': 0, 'reasonText': 'Failed to send message: $e'},
});
}
} else {
completer.complete({'error': 'Not connected to server'});
}
return completer.future;
}
void bindMessageToFunction(
String pName,
Function(Map<String, dynamic>) function,
) {
try {
_boundMessages[pName] = function;
} catch (e) {
debugPrint('Sockets bindMessageToFunction error: $e');
}
}
void _setPendingMessageTimeout(String messageKey, int timeout) {
try {
_pendingMessagesTimeout[messageKey] = Timer(
Duration(seconds: timeout),
() {
// Track socket timeout error
// final trackingService = NetworkTrackingService();
// final parts = messageKey.split('-');
// if (parts.isNotEmpty) {
// trackingService.trackSocketError(
// messageName: parts[0],
// error: 'Message timeout',
// );
// }
final connectionService = ConnectionService();
final isConnected =
connectionService.currentStatus?.isConnected ?? false;
final context = navigatorKey.currentContext;
final noInternetText = context?.translate('noInternetError');
final noConnectionText = context?.translate('noConnectionError');
_completeAndRemovePendingMessage(messageKey, {
'pData': {
'status': 0,
'reasonText':
isConnected == false ? noInternetText : noConnectionText,
},
});
},
);
} catch (e) {
debugPrint('Sockets _setPendingMessageTimeout error: $e');
}
}
void _cancelPendingMessageTimeout(String messageKey) {
try {
if (_pendingMessagesTimeout.containsKey(messageKey)) {
_pendingMessagesTimeout[messageKey]!.cancel();
_pendingMessagesTimeout.remove(messageKey);
}
} catch (e) {
debugPrint('Sockets _cancelPendingMessageTimeout error: $e');
}
}
void _completeAndRemovePendingMessage(
String messageKey,
Map<String, dynamic> response,
) {
try {
Completer<Map<String, dynamic>>? completer = _pendingMessages[messageKey];
if (completer != null && !completer.isCompleted) {
completer.complete(response);
_pendingMessages.remove(messageKey);
_cancelPendingMessageTimeout(messageKey);
}
} catch (e) {
debugPrint('Sockets _completeAndRemovePendingMessage error: $e');
}
}
Map<String, dynamic> buildDefaultPayloadData(
String pName,
Map<String, dynamic> pData, {
int? pType,
String? pId,
}) {
try {
pType ??= 1;
pId ??= createPid();
return {'pId': pId, 'pName': pName, 'pType': pType, 'pData': pData};
} catch (e) {
debugPrint('Sockets buildDefaultPayloadData error: $e');
// Fallback to a simple timestamp-based ID if createPid fails
final fallbackId = DateTime.now().millisecondsSinceEpoch.toString();
return {
'pId': fallbackId,
'pName': pName,
'pType': pType ?? 1,
'pData': pData,
};
}
}
Future<void> disconnect() async {
Completer<void> completer = Completer<void>();
// _manuallyDisconnected = true;
_reconnectTimer?.cancel();
_cleanupTimer?.cancel();
_messageSubscription?.cancel();
setIsConnectionEstablished(false);
if (_channel == null) {
Future.delayed(const Duration(seconds: 1), () async {
completer.complete();
});
} else {
_isSocketConnected = false;
try {
await _channel!.sink.close();
_channel = null;
} catch (e) {
debugPrint('Sockets disconnect error: $e');
}
// Complete any remaining pending messages with empty response
_clearPendingMessages();
Future.delayed(const Duration(seconds: 1), () async {
completer.complete();
notifyListeners();
});
}
return completer.future;
}
void _clearPendingMessages() {
try {
List<String> keysToRemove = _pendingMessages.keys.toList();
for (final messageKey in keysToRemove) {
// Track socket error for connection closed
// final trackingService = NetworkTrackingService();
// final parts = messageKey.split('-');
// if (parts.isNotEmpty) {
// trackingService.trackSocketError(
// messageName: parts[0],
// error: 'Connection closed',
// );
// }
final connectionService = ConnectionService();
final isConnected =
connectionService.currentStatus?.isConnected ?? false;
final context = navigatorKey.currentContext;
final noInternetText = context?.translate('noInternetError');
final noConnectionText = context?.translate('noConnectionError');
final reasonText =
isConnected == false ? noInternetText : noConnectionText;
_completeAndRemovePendingMessage(messageKey, {
'pData': {'status': 0, 'reasonText': reasonText},
});
}
} catch (e) {
debugPrint('Sockets _clearPendingMessages error: $e');
}
}
void _cleanupOldPendingMessages() {
try {
// Clean up pending messages that are older than 30 seconds
final now = DateTime.now().millisecondsSinceEpoch;
List<String> keysToRemove = [];
for (final messageKey in _pendingMessages.keys) {
try {
// Extract timestamp from message key (format: pName-timestamp-counter)
final parts = messageKey.split('-');
if (parts.length >= 2) {
final timestamp = int.tryParse(parts[1]);
if (timestamp != null && (now - timestamp) > 30000) {
// 30 seconds
keysToRemove.add(messageKey);
}
}
} catch (e) {
debugPrint(
'Sockets _cleanupOldPendingMessages parse error for key $messageKey: $e',
);
}
}
for (final messageKey in keysToRemove) {
// Track socket error for old message cleanup
// final trackingService = NetworkTrackingService();
// final parts = messageKey.split('-');
// if (parts.isNotEmpty) {
// trackingService.trackSocketError(
// messageName: parts[0],
// error: 'Message timeout - cleaned up',
// );
// }
_completeAndRemovePendingMessage(messageKey, {
'error': 'Message timeout - cleaned up',
});
}
} catch (e) {
debugPrint('Sockets _cleanupOldPendingMessages error: $e');
}
}
Future<bool> _canReconnect() async {
try {
final apiService = ApiService();
final result = await apiService.request(
'PingWithAuth',
data: {},
showError: false,
);
if (result.data != null &&
result.data['status'] == 0 &&
result.data['reason'] == 'noAuth') {
return false;
}
return true;
} catch (e) {
return false;
}
}
void _tryReconnect() async {
if (_isSocketConnected) return;
final reconnectAllowed = await _canReconnect();
if (!reconnectAllowed) {
onNoAuth?.call();
return;
}
_reconnectTimer?.cancel();
// _reconnectAttempts++;
_reconnectTimer = Timer(Duration(seconds: _reconnectDelay), () async {
try {
final connResult = await initConnection();
if (connResult['status'] == 1) {
_reconnectAttempts = 0;
onConnectionRestoredCallback?.call();
} else {
// if (_reconnectAttempts == 2) {
final connectionService = ConnectionService();
final isConnected =
connectionService.currentStatus?.isConnected ?? false;
onConnectionErrorCallback?.call(isConnected == true ? '20' : '10');
// }
}
} catch (e) {
debugPrint('Sockets _tryReconnect error: $e');
if (_reconnectAttempts == 2) {
final connectionService = ConnectionService();
final isConnected =
connectionService.currentStatus?.isConnected ?? false;
onConnectionErrorCallback?.call(isConnected == true ? '20' : '10');
}
}
});
}
// Public method to allow external services to trigger reconnect attempts
void triggerReconnect() {
_tryReconnect();
}
// Helper method to send acknowledgment without creating pending messages
void _sendAcknowledgment(String pName, String? pId) {
if (_channel != null && _isSocketConnected) {
Map<String, dynamic> payload = {
'pId': pId ?? createPid(),
'pName': pName,
'pType': 2,
'pData': {'status': 1, 'reason': ''},
};
String jsonMessage;
try {
jsonMessage = jsonEncode(payload);
} catch (e) {
debugPrint('Sockets _sendAcknowledgment jsonEncode error: $e');
return;
}
try {
_channel!.sink.add(jsonMessage);
} catch (e) {
debugPrint('Sockets acknowledgment send error: $e');
_isSocketConnected = false;
}
} else {
debugPrint('Sockets sendAcknowledgment DEBUG: Cannot send ACK');
}
}
}
Thanks