2025-12-26 11:42:02 -07:00
import ' dart:async ' ;
2026-01-02 14:22:39 -07:00
import ' dart:convert ' ;
2025-12-26 11:42:02 -07:00
import ' package:flutter/foundation.dart ' ;
import ' package:uuid/uuid.dart ' ;
2026-01-02 14:22:39 -07:00
import ' package:crypto/crypto.dart ' ;
2025-12-26 11:42:02 -07:00
import ' ../models/contact.dart ' ;
import ' ../models/message.dart ' ;
import ' ../models/path_selection.dart ' ;
import ' app_settings_service.dart ' ;
2026-01-02 14:22:39 -07:00
import ' app_debug_log_service.dart ' ;
2025-12-26 11:42:02 -07:00
2025-12-30 21:42:14 -07:00
class _AckHistoryEntry {
final String messageId ;
2026-03-21 13:01:02 -07:00
final List < int > ackHashes ;
2025-12-30 21:42:14 -07:00
final DateTime timestamp ;
_AckHistoryEntry ( {
required this . messageId ,
required this . ackHashes ,
required this . timestamp ,
} ) ;
}
2026-03-20 01:54:31 -07:00
/// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup.
2026-03-20 01:55:08 -07:00
typedef AckHashMapping = ( {
String messageId ,
DateTime timestamp ,
int attemptIndex ,
} ) ;
2026-03-20 01:54:31 -07:00
class RetryServiceConfig {
final void Function ( Contact , String , int , int ) sendMessage ;
final void Function ( String , Message ) addMessage ;
final void Function ( Message ) updateMessage ;
final Function ( Contact ) ? clearContactPath ;
final Function ( Contact , Uint8List , int ) ? setContactPath ;
final int Function ( int pathLength , int messageBytes , { String ? contactKey } ) ?
2026-03-20 01:55:08 -07:00
calculateTimeout ;
2026-03-20 01:54:31 -07:00
final Uint8List ? Function ( ) ? getSelfPublicKey ;
final String Function ( Contact , String ) ? prepareContactOutboundText ;
final AppSettingsService ? appSettingsService ;
final AppDebugLogService ? debugLogService ;
final void Function ( String , PathSelection , bool , int ? ) ? recordPathResult ;
final void Function ( String , int , int , int ) ? onDeliveryObserved ;
final PathSelection ? Function (
String contactKey ,
int attemptIndex ,
int maxRetries ,
List < PathSelection > recentSelections ,
2026-03-20 01:55:08 -07:00
) ?
selectRetryPath ;
2026-03-20 01:54:31 -07:00
const RetryServiceConfig ( {
required this . sendMessage ,
required this . addMessage ,
required this . updateMessage ,
this . clearContactPath ,
this . setContactPath ,
this . calculateTimeout ,
this . getSelfPublicKey ,
this . prepareContactOutboundText ,
this . appSettingsService ,
this . debugLogService ,
this . recordPathResult ,
this . onDeliveryObserved ,
this . selectRetryPath ,
} ) ;
2025-12-31 22:19:48 -07:00
}
2025-12-26 11:42:02 -07:00
class MessageRetryService extends ChangeNotifier {
2025-12-30 21:42:14 -07:00
static const int maxAckHistorySize = 100 ;
2026-03-20 01:54:31 -07:00
int _maxRetries = 5 ;
int get maxRetries = > _maxRetries ;
2025-12-26 11:42:02 -07:00
final Map < String , Timer > _timeoutTimers = { } ;
final Map < String , Message > _pendingMessages = { } ;
final Map < String , Contact > _pendingContacts = { } ;
2026-03-20 01:54:31 -07:00
final Map < String , List < PathSelection > > _attemptPathHistory = { } ;
final Map < String , AckHashMapping > _ackHashToMessageId = { } ;
2026-03-21 13:01:02 -07:00
final Map < String , List < int > > _expectedAckHashes = { } ;
2026-03-20 01:54:31 -07:00
final List < _AckHistoryEntry > _ackHistory = [ ] ;
final Map < String , List < String > > _sendQueue = { } ;
final Set < String > _activeMessages = { } ;
final Set < String > _resolvedMessages = { } ;
final Map < String , String > _expectedHashToMessageId = { } ;
RetryServiceConfig ? _config ;
2025-12-26 11:42:02 -07:00
2026-01-17 11:00:34 -05:00
MessageRetryService ( ) ;
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
void initialize ( RetryServiceConfig config ) {
_config = config ;
}
void setMaxRetries ( int value ) {
_maxRetries = value . clamp ( 2 , 10 ) ;
2025-12-26 11:42:02 -07:00
}
2026-01-02 14:22:39 -07:00
/// Compute expected ACK hash using same algorithm as firmware:
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
2026-03-22 10:50:11 -07:00
static int computeExpectedAckHash (
2026-01-02 14:22:39 -07:00
int timestampSeconds ,
int attempt ,
String text ,
Uint8List senderPubKey ,
) {
final textBytes = utf8 . encode ( text ) ;
final buffer = Uint8List ( 4 + 1 + textBytes . length + senderPubKey . length ) ;
int offset = 0 ;
// timestamp (4 bytes, little-endian)
buffer [ offset + + ] = timestampSeconds & 0xFF ;
buffer [ offset + + ] = ( timestampSeconds > > 8 ) & 0xFF ;
buffer [ offset + + ] = ( timestampSeconds > > 16 ) & 0xFF ;
buffer [ offset + + ] = ( timestampSeconds > > 24 ) & 0xFF ;
// attempt (1 byte)
buffer [ offset + + ] = attempt & 0x03 ;
// text
buffer . setRange ( offset , offset + textBytes . length , textBytes ) ;
offset + = textBytes . length ;
// sender public key (32 bytes)
buffer . setRange ( offset , offset + senderPubKey . length , senderPubKey ) ;
// Compute SHA256 and return first 4 bytes
final hash = sha256 . convert ( buffer ) ;
2026-03-22 10:50:11 -07:00
final bytes = Uint8List . fromList ( hash . bytes . sublist ( 0 , 4 ) ) ;
return ( bytes [ 3 ] < < 24 ) | ( bytes [ 2 ] < < 16 ) | ( bytes [ 1 ] < < 8 ) | bytes [ 0 ] ;
2026-01-02 14:22:39 -07:00
}
2025-12-26 11:42:02 -07:00
Future < void > sendMessageWithRetry ( {
required Contact contact ,
required String text ,
Uint8List ? pathBytes ,
int ? pathLength ,
} ) async {
final messageId = const Uuid ( ) . v4 ( ) ;
2026-03-20 01:54:31 -07:00
final resolved = resolvePathSelection ( contact ) ;
2026-03-20 01:55:08 -07:00
final messagePathBytes =
pathBytes ? ? Uint8List . fromList ( resolved . pathBytes ) ;
2025-12-26 11:42:02 -07:00
final messagePathLength =
2026-03-20 01:54:31 -07:00
pathLength ? ? ( resolved . useFlood ? - 1 : resolved . hopCount ) ;
2025-12-26 11:42:02 -07:00
final message = Message (
senderKey: contact . publicKey ,
text: text ,
timestamp: DateTime . now ( ) ,
isOutgoing: true ,
status: MessageStatus . pending ,
messageId: messageId ,
retryCount: 0 ,
pathLength: messagePathLength ,
pathBytes: messagePathBytes ,
) ;
_pendingMessages [ messageId ] = message ;
_pendingContacts [ messageId ] = contact ;
2026-03-20 01:54:31 -07:00
_config ? . addMessage ( contact . publicKeyHex , message ) ;
2025-12-26 11:42:02 -07:00
2026-03-14 09:33:37 -07:00
// Queue per contact — only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
final contactKey = contact . publicKeyHex ;
_sendQueue [ contactKey ] ? ? = [ ] ;
_sendQueue [ contactKey ] ! . add ( messageId ) ;
if ( ! _activeMessages . any (
( id ) = > _pendingContacts [ id ] ? . publicKeyHex = = contactKey ,
) ) {
_sendNextForContact ( contactKey ) ;
}
}
void _sendNextForContact ( String contactKey ) {
final queue = _sendQueue [ contactKey ] ;
if ( queue = = null ) return ;
// Drain stale entries iteratively instead of recursing.
while ( queue . isNotEmpty ) {
final messageId = queue . removeAt ( 0 ) ;
if ( _pendingMessages . containsKey ( messageId ) ) {
_activeMessages . add ( messageId ) ;
2026-03-14 09:54:50 -07:00
_attemptSend ( messageId ) . catchError ( ( e ) {
debugPrint ( ' _attemptSend threw for $ messageId : $ e ' ) ;
final msg = _pendingMessages [ messageId ] ;
if ( msg ! = null ) {
final failed = msg . copyWith ( status: MessageStatus . failed ) ;
_pendingMessages [ messageId ] = failed ;
2026-03-20 01:54:31 -07:00
_config ? . updateMessage ( failed ) ;
2026-03-14 09:54:50 -07:00
}
_onMessageResolved ( messageId , contactKey ) ;
} ) ;
2026-03-14 09:33:37 -07:00
return ;
}
}
}
void _onMessageResolved ( String messageId , String contactKey ) {
if ( _resolvedMessages . contains ( messageId ) ) return ;
_resolvedMessages . add ( messageId ) ;
_activeMessages . remove ( messageId ) ;
_sendNextForContact ( contactKey ) ;
2025-12-26 11:42:02 -07:00
}
2026-03-20 01:54:31 -07:00
PathSelection ? _selectPathForAttempt ( Message message , Contact contact ) {
final config = _config ;
if ( config = = null ) return null ;
final autoRotationEnabled =
config . appSettingsService ? . settings . autoRouteRotationEnabled = = true ;
if ( ! autoRotationEnabled | |
contact . pathOverride ! = null | |
config . selectRetryPath = = null ) {
return null ;
}
final recentSelections = List < PathSelection > . from (
_attemptPathHistory [ message . messageId ] ? ? const < PathSelection > [ ] ,
) ;
return config . selectRetryPath ! (
contact . publicKeyHex ,
message . retryCount ,
maxRetries ,
recentSelections ,
) ;
}
void _recordAttemptPathHistory ( String messageId , PathSelection selection ) {
if ( selection . useFlood ) return ;
final history = _attemptPathHistory . putIfAbsent ( messageId , ( ) = > [ ] ) ;
history . add ( selection ) ;
if ( history . length > recentAttemptDiversityWindow ) {
history . removeAt ( 0 ) ;
}
}
2025-12-26 11:42:02 -07:00
Future < void > _attemptSend ( String messageId ) async {
final message = _pendingMessages [ messageId ] ;
final contact = _pendingContacts [ messageId ] ;
2026-03-20 01:54:31 -07:00
final config = _config ;
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
if ( message = = null | | contact = = null | | config = = null ) return ;
final currentSelection = _selectPathForAttempt ( message , contact ) ;
if ( currentSelection ! = null ) {
final updatedMessage = message . copyWith (
pathLength: currentSelection . useFlood ? - 1 : currentSelection . hopCount ,
pathBytes: currentSelection . useFlood
? Uint8List ( 0 )
: Uint8List . fromList ( currentSelection . pathBytes ) ,
) ;
_pendingMessages [ messageId ] = updatedMessage ;
} else if ( message . retryCount > 0 ) {
// No schedule entry for this retry — re-resolve path from current contact
// state so user's path override changes are picked up between retries.
final resolved = resolvePathSelection ( contact ) ;
final updatedMessage = message . copyWith (
pathLength: resolved . useFlood ? - 1 : resolved . hopCount ,
pathBytes: Uint8List . fromList ( resolved . pathBytes ) ,
) ;
_pendingMessages [ messageId ] = updatedMessage ;
}
// Re-read after potential schedule update
final effectiveMessage = _pendingMessages [ messageId ] ? ? message ;
2025-12-26 11:42:02 -07:00
2025-12-30 21:42:14 -07:00
// Sync path settings with device before sending
2026-03-20 01:54:31 -07:00
if ( config . setContactPath ! = null & & config . clearContactPath ! = null ) {
final bool useFlood = currentSelection ! = null
? currentSelection . useFlood
2026-03-20 01:55:08 -07:00
: ( effectiveMessage . pathLength ! = null & &
effectiveMessage . pathLength ! < 0 ) ;
2026-03-20 01:54:31 -07:00
final List < int > pathBytes = currentSelection ! = null
? currentSelection . pathBytes
: effectiveMessage . pathBytes ;
final int hopCount = currentSelection ! = null
? currentSelection . hopCount
: ( effectiveMessage . pathLength ? ? 0 ) ;
if ( useFlood ) {
await config . clearContactPath ! ( contact ) ;
} else if ( effectiveMessage . pathLength ! = null ) {
await config . setContactPath ! (
2026-02-04 08:32:35 -08:00
contact ,
2026-03-20 01:54:31 -07:00
Uint8List . fromList ( pathBytes ) ,
hopCount ,
2026-02-04 08:32:35 -08:00
) ;
2025-12-30 21:42:14 -07:00
}
}
2026-03-14 09:33:37 -07:00
// Re-validate after async gap — a timer or ACK could have resolved/retried
// this message while we were awaiting the path callback.
final currentMessage = _pendingMessages [ messageId ] ;
if ( currentMessage = = null | | _resolvedMessages . contains ( messageId ) ) {
debugPrint (
' _attemptSend: message $ messageId resolved during path sync, aborting ' ,
) ;
return ;
}
if ( currentMessage . retryCount ! = message . retryCount ) {
debugPrint (
' _attemptSend: message $ messageId retryCount changed during path sync, aborting ' ,
) ;
return ;
}
2026-03-20 01:54:31 -07:00
if ( currentSelection ! = null ) {
_recordAttemptPathHistory ( messageId , currentSelection ) ;
}
final attempt = message . retryCount ;
2026-01-02 14:22:39 -07:00
final timestampSeconds = message . timestamp . millisecondsSinceEpoch ~ / 1000 ;
// Compute expected ACK hash that device will return in RESP_CODE_SENT
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
2026-03-20 01:54:31 -07:00
final selfPubKey = config . getSelfPublicKey ? . call ( ) ;
2026-01-02 14:22:39 -07:00
if ( selfPubKey ! = null ) {
2026-02-04 08:32:35 -08:00
final outboundText =
2026-03-20 01:54:31 -07:00
config . prepareContactOutboundText ? . call ( contact , message . text ) ? ?
2026-02-04 08:32:35 -08:00
message . text ;
2026-01-02 14:22:39 -07:00
final expectedHash = MessageRetryService . computeExpectedAckHash (
timestampSeconds ,
attempt ,
outboundText ,
selfPubKey ,
) ;
2026-03-22 10:50:11 -07:00
final expectedHashHex = expectedHash . toRadixString ( 16 ) . padLeft ( 8 , ' 0 ' ) ;
2026-01-02 14:22:39 -07:00
_expectedHashToMessageId [ expectedHashHex ] = messageId ;
2026-02-04 08:32:35 -08:00
final shortText = message . text . length > 20
? ' ${ message . text . substring ( 0 , 20 ) } ... '
: message . text ;
2026-03-20 01:54:31 -07:00
config . debugLogService ? . info (
2026-01-02 14:22:39 -07:00
' Sent " $ shortText " to ${ contact . name } → expect ACK hash $ expectedHashHex (attempt $ attempt ) ' ,
tag: ' AckHash ' ,
) ;
}
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
config . sendMessage ( contact , message . text , attempt , timestampSeconds ) ;
2025-12-26 11:42:02 -07:00
}
2026-03-21 13:01:02 -07:00
bool updateMessageFromSent ( int ackHash , int timeoutMs ) {
2026-03-20 01:54:31 -07:00
final config = _config ;
if ( config = = null ) return false ;
2026-03-21 13:01:02 -07:00
final ackHashHex = ackHash . toRadixString ( 16 ) . padLeft ( 8 , ' 0 ' ) ;
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
// Try hash-based matching (fixes LoRa message drops causing mismatches)
2026-01-02 14:22:39 -07:00
String ? messageId = _expectedHashToMessageId . remove ( ackHashHex ) ;
2025-12-31 22:19:48 -07:00
Contact ? contact ;
2026-01-02 14:22:39 -07:00
if ( messageId ! = null ) {
contact = _pendingContacts [ messageId ] ;
final message = _pendingMessages [ messageId ] ;
2025-12-30 21:42:14 -07:00
2026-01-02 14:22:39 -07:00
if ( contact ! = null & & message ! = null ) {
2026-02-04 08:32:35 -08:00
final shortText = message . text . length > 20
? ' ${ message . text . substring ( 0 , 20 ) } ... '
: message . text ;
2026-03-20 01:54:31 -07:00
config . debugLogService ? . info (
2026-01-02 14:22:39 -07:00
' RESP_CODE_SENT received: ACK hash $ ackHashHex ✓ matched " $ shortText " to ${ contact . name } ' ,
tag: ' AckHash ' ,
) ;
} else {
2026-03-20 01:54:31 -07:00
config . debugLogService ? . warn (
2026-01-02 14:22:39 -07:00
' RESP_CODE_SENT: ACK hash $ ackHashHex matched but message no longer pending ' ,
tag: ' AckHash ' ,
) ;
messageId = null ;
contact = null ;
}
}
2025-12-31 22:19:48 -07:00
if ( messageId = = null | | contact = = null ) {
2026-01-02 14:22:39 -07:00
debugPrint ( ' No pending message found for ACK hash: $ ackHashHex ' ) ;
2026-02-21 18:31:51 -05:00
return false ;
2025-12-31 22:19:48 -07:00
}
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
final message = _pendingMessages [ messageId ] ! ;
_ackHashToMessageId [ ackHashHex ] = (
2025-12-31 22:19:48 -07:00
messageId: messageId ,
timestamp: DateTime . now ( ) ,
2026-03-20 01:54:31 -07:00
attemptIndex: message . retryCount ,
2025-12-31 22:19:48 -07:00
) ;
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes [ messageId ] ? ? = [ ] ;
2026-03-21 13:01:02 -07:00
if ( ! _expectedAckHashes [ messageId ] ! . any ( ( hash ) = > hash = = ackHash ) ) {
_expectedAckHashes [ messageId ] ! . add ( ackHash ) ;
2025-12-31 22:19:48 -07:00
}
2026-03-14 16:56:11 -07:00
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
2026-03-20 01:54:31 -07:00
final pathLengthValue = message . pathLength ? ? contact . pathLength ;
2026-03-14 16:56:11 -07:00
2025-12-31 22:19:48 -07:00
int actualTimeout = timeoutMs ;
2026-03-20 01:54:31 -07:00
if ( config . calculateTimeout ! = null ) {
final calculated = config . calculateTimeout ! (
2026-02-04 08:32:35 -08:00
pathLengthValue ,
message . text . length ,
2026-03-14 16:56:11 -07:00
contactKey: contact . publicKeyHex ,
2026-02-04 08:32:35 -08:00
) ;
2026-03-14 16:56:11 -07:00
if ( timeoutMs < = 0 | | calculated < timeoutMs ) {
actualTimeout = calculated ;
}
2025-12-31 22:19:48 -07:00
}
final updatedMessage = message . copyWith (
status: MessageStatus . sent ,
expectedAckHash: ackHash ,
estimatedTimeoutMs: actualTimeout ,
sentAt: DateTime . now ( ) ,
) ;
_pendingMessages [ messageId ] = updatedMessage ;
2026-03-20 01:54:31 -07:00
config . updateMessage ( updatedMessage ) ;
2025-12-31 22:19:48 -07:00
_startTimeoutTimer ( messageId , actualTimeout ) ;
2026-02-21 18:31:51 -05:00
return true ;
2025-12-26 11:42:02 -07:00
}
2026-02-21 18:31:51 -05:00
bool get hasPendingMessages = > _pendingMessages . isNotEmpty ;
2026-03-20 01:54:31 -07:00
/// Update the stored contact snapshot for all pending messages to this contact.
/// Call this when the contact's pathOverride changes so retries use the new path.
void updatePendingContact ( Contact contact ) {
final keys = _pendingContacts . entries
. where ( ( e ) = > e . value . publicKeyHex = = contact . publicKeyHex )
. map ( ( e ) = > e . key )
. toList ( ) ;
for ( final key in keys ) {
_pendingContacts [ key ] = contact ;
}
}
2025-12-26 11:42:02 -07:00
void _startTimeoutTimer ( String messageId , int timeoutMs ) {
_timeoutTimers [ messageId ] ? . cancel ( ) ;
_timeoutTimers [ messageId ] = Timer ( Duration ( milliseconds: timeoutMs ) , ( ) {
_handleTimeout ( messageId ) ;
} ) ;
}
2026-03-20 01:54:31 -07:00
void _cleanupMessage ( String messageId ) {
_moveAckHashesToHistory ( messageId ) ;
_ackHashToMessageId . removeWhere (
( _ , mapping ) = > mapping . messageId = = messageId ,
) ;
_expectedHashToMessageId . removeWhere ( ( _ , msgId ) = > msgId = = messageId ) ;
_pendingMessages . remove ( messageId ) ;
_pendingContacts . remove ( messageId ) ;
_attemptPathHistory . remove ( messageId ) ;
_timeoutTimers . remove ( messageId ) ;
_resolvedMessages . remove ( messageId ) ;
}
2025-12-26 11:42:02 -07:00
void _handleTimeout ( String messageId ) {
final message = _pendingMessages [ messageId ] ;
final contact = _pendingContacts [ messageId ] ;
2026-03-20 01:54:31 -07:00
final config = _config ;
final selection = message ! = null ? _selectionFromMessage ( message ) : null ;
2025-12-26 11:42:02 -07:00
2025-12-31 22:19:48 -07:00
if ( message = = null | | contact = = null ) {
2026-02-04 08:32:35 -08:00
debugPrint (
' Timeout fired but message $ messageId no longer pending (likely already delivered) ' ,
) ;
2025-12-31 22:19:48 -07:00
return ;
}
2025-12-26 11:42:02 -07:00
2026-02-04 08:32:35 -08:00
final shortText = message . text . length > 20
? ' ${ message . text . substring ( 0 , 20 ) } ... '
: message . text ;
2026-03-20 01:54:31 -07:00
config ? . debugLogService ? . warn (
2026-01-02 14:22:39 -07:00
' Timeout: No ACK received for " $ shortText " to ${ contact . name } (attempt ${ message . retryCount } ) → retrying ' ,
tag: ' AckHash ' ,
) ;
2025-12-30 21:42:14 -07:00
2025-12-26 11:42:02 -07:00
if ( message . retryCount < maxRetries - 1 ) {
final backoffMs = 1000 * ( 1 < < message . retryCount ) ;
2026-03-20 01:54:31 -07:00
if ( selection ! = null ) {
_recordPathResultFromMessage (
contact . publicKeyHex ,
message ,
selection ,
false ,
null ,
) ;
}
2025-12-26 11:42:02 -07:00
final updatedMessage = message . copyWith (
retryCount: message . retryCount + 1 ,
status: MessageStatus . pending ,
) ;
_pendingMessages [ messageId ] = updatedMessage ;
2026-03-20 01:54:31 -07:00
config ? . updateMessage ( updatedMessage ) ;
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
config ? . debugLogService ? . info (
2026-01-02 14:22:39 -07:00
' Scheduling retry for " $ shortText " to ${ contact . name } after ${ backoffMs } ms backoff ' ,
tag: ' AckHash ' ,
) ;
_timeoutTimers [ messageId ] = Timer ( Duration ( milliseconds: backoffMs ) , ( ) {
2025-12-31 22:19:48 -07:00
if ( _pendingMessages . containsKey ( messageId ) ) {
_attemptSend ( messageId ) ;
}
2025-12-26 11:42:02 -07:00
} ) ;
} else {
// Max retries reached - mark as failed
final failedMessage = message . copyWith ( status: MessageStatus . failed ) ;
2026-03-14 09:33:37 -07:00
_pendingMessages [ messageId ] = failedMessage ;
2025-12-31 22:19:48 -07:00
2026-03-20 01:54:31 -07:00
if ( config ? . appSettingsService ? . settings . clearPathOnMaxRetry = = true & &
config ? . clearContactPath ! = null ) {
config ! . clearContactPath ! ( contact ) ;
2025-12-26 11:42:02 -07:00
}
2026-02-04 08:32:35 -08:00
_recordPathResultFromMessage (
contact . publicKeyHex ,
message ,
selection ,
false ,
null ,
) ;
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
config ? . updateMessage ( failedMessage ) ;
2025-12-26 11:42:02 -07:00
notifyListeners ( ) ;
2026-03-14 09:33:37 -07:00
_onMessageResolved ( messageId , contact . publicKeyHex ) ;
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers [ messageId ] = Timer ( const Duration ( seconds: 30 ) , ( ) {
2026-03-20 01:54:31 -07:00
_cleanupMessage ( messageId ) ;
2026-03-14 09:33:37 -07:00
} ) ;
2025-12-26 11:42:02 -07:00
}
}
2025-12-30 21:42:14 -07:00
void _moveAckHashesToHistory ( String messageId ) {
final ackHashes = _expectedAckHashes . remove ( messageId ) ;
if ( ackHashes ! = null & & ackHashes . isNotEmpty ) {
2026-02-04 08:32:35 -08:00
_ackHistory . add (
_AckHistoryEntry (
messageId: messageId ,
ackHashes: ackHashes ,
timestamp: DateTime . now ( ) ,
) ,
) ;
2025-12-30 21:42:14 -07:00
while ( _ackHistory . length > maxAckHistorySize ) {
_ackHistory . removeAt ( 0 ) ;
}
}
}
2026-03-21 13:01:02 -07:00
bool _checkAckHistory ( int ackHash ) {
2025-12-30 21:42:14 -07:00
for ( final entry in _ackHistory ) {
for ( final expectedHash in entry . ackHashes ) {
2026-03-21 13:01:02 -07:00
if ( expectedHash = = ackHash ) {
2025-12-30 21:42:14 -07:00
return true ;
}
}
}
return false ;
}
2026-03-21 13:01:02 -07:00
void handleAckReceived ( int ackHash , int tripTimeMs ) {
2026-03-20 01:54:31 -07:00
final config = _config ;
2025-12-26 11:42:02 -07:00
String ? matchedMessageId ;
2026-03-20 01:54:31 -07:00
int ? matchedAttemptIndex ;
2026-03-21 13:01:02 -07:00
final ackHashHex = ackHash . toRadixString ( 16 ) . padLeft ( 8 , ' 0 ' ) ;
2025-12-26 11:42:02 -07:00
2026-03-20 01:54:31 -07:00
// Clean up old ACK hash mappings (older than 15 minutes)
2025-12-31 22:19:48 -07:00
final cutoffTime = DateTime . now ( ) . subtract ( const Duration ( minutes: 15 ) ) ;
final hashesToRemove = < String > [ ] ;
for ( var entry in _ackHashToMessageId . entries ) {
if ( entry . value . timestamp . isBefore ( cutoffTime ) ) {
hashesToRemove . add ( entry . key ) ;
}
}
for ( var hash in hashesToRemove ) {
_ackHashToMessageId . remove ( hash ) ;
}
2025-12-30 21:42:14 -07:00
2025-12-31 22:19:48 -07:00
// Use direct O(1) lookup via ACK hash mapping
final mapping = _ackHashToMessageId [ ackHashHex ] ;
if ( mapping ! = null ) {
matchedMessageId = mapping . messageId ;
2026-03-20 01:54:31 -07:00
matchedAttemptIndex = mapping . attemptIndex ;
2025-12-31 22:19:48 -07:00
} else {
2026-03-20 01:54:31 -07:00
config ? . debugLogService ? . warn (
2026-01-02 14:22:39 -07:00
' PUSH_CODE_SEND_CONFIRMED: ACK hash $ ackHashHex not found in direct mapping, trying fallback ' ,
tag: ' AckHash ' ,
) ;
2025-12-31 22:19:48 -07:00
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
for ( var entry in _expectedAckHashes . entries ) {
final messageId = entry . key ;
final expectedHashes = entry . value ;
for ( final expectedHash in expectedHashes ) {
2026-03-21 13:01:02 -07:00
if ( expectedHash = = ackHash ) {
2025-12-31 22:19:48 -07:00
matchedMessageId = messageId ;
2026-03-20 01:54:31 -07:00
matchedAttemptIndex = expectedHashes . indexOf ( expectedHash ) ;
2025-12-31 22:19:48 -07:00
break ;
}
2025-12-30 21:42:14 -07:00
}
2025-12-31 22:19:48 -07:00
if ( matchedMessageId ! = null ) break ;
}
2025-12-26 11:42:02 -07:00
}
if ( matchedMessageId ! = null ) {
2026-03-14 09:33:37 -07:00
final message = _pendingMessages [ matchedMessageId ] ;
if ( message = = null ) {
_ackHashToMessageId . remove ( ackHashHex ) ;
return ;
}
2025-12-26 11:42:02 -07:00
final contact = _pendingContacts [ matchedMessageId ] ;
2026-03-20 01:54:31 -07:00
final ackedAttempt = matchedAttemptIndex ? ? message . retryCount ;
final selection = _selectionFromMessage ( message ) ;
2025-12-30 21:42:14 -07:00
2026-02-04 08:32:35 -08:00
final shortText = message . text . length > 20
? ' ${ message . text . substring ( 0 , 20 ) } ... '
: message . text ;
2026-03-20 01:54:31 -07:00
config ? . debugLogService ? . info (
' PUSH_CODE_SEND_CONFIRMED: ACK hash $ ackHashHex ✓ " $ shortText " delivered to ${ contact ? . name ? ? " unknown " } on retry ${ ackedAttempt + 1 } in ${ tripTimeMs } ms ' ,
2026-01-02 14:22:39 -07:00
tag: ' AckHash ' ,
) ;
2025-12-26 11:42:02 -07:00
_timeoutTimers [ matchedMessageId ] ? . cancel ( ) ;
final deliveredMessage = message . copyWith (
status: MessageStatus . delivered ,
deliveredAt: DateTime . now ( ) ,
tripTimeMs: tripTimeMs ,
) ;
2026-03-20 01:54:31 -07:00
_cleanupMessage ( matchedMessageId ) ;
2026-03-14 09:33:37 -07:00
2026-03-20 01:54:31 -07:00
config ? . updateMessage ( deliveredMessage ) ;
2025-12-26 11:42:02 -07:00
if ( contact ! = null ) {
2026-02-04 08:32:35 -08:00
_recordPathResultFromMessage (
contact . publicKeyHex ,
message ,
selection ,
true ,
tripTimeMs ,
) ;
2026-03-20 01:54:31 -07:00
if ( config ? . onDeliveryObserved ! = null & &
2026-03-14 17:32:08 -07:00
tripTimeMs > 0 & &
message . pathLength ! = null ) {
2026-03-20 01:54:31 -07:00
config ! . onDeliveryObserved ! (
2026-03-14 16:56:11 -07:00
contact . publicKeyHex ,
2026-03-14 17:32:08 -07:00
message . pathLength ! ,
2026-03-14 16:56:11 -07:00
message . text . length ,
tripTimeMs ,
) ;
}
2026-03-14 09:33:37 -07:00
_onMessageResolved ( matchedMessageId , contact . publicKeyHex ) ;
2025-12-26 11:42:02 -07:00
}
notifyListeners ( ) ;
2025-12-30 21:42:14 -07:00
} else {
if ( _checkAckHistory ( ackHash ) ) {
2026-03-20 01:54:31 -07:00
config ? . debugLogService ? . info (
2026-01-02 14:22:39 -07:00
' PUSH_CODE_SEND_CONFIRMED: ACK hash $ ackHashHex matched a recently completed message (duplicate ACK) ' ,
tag: ' AckHash ' ,
) ;
2025-12-30 21:42:14 -07:00
} else {
2026-03-20 01:54:31 -07:00
config ? . debugLogService ? . error (
2026-01-02 14:22:39 -07:00
' PUSH_CODE_SEND_CONFIRMED: ACK hash $ ackHashHex has no matching message! ' ,
tag: ' AckHash ' ,
) ;
2025-12-30 21:42:14 -07:00
debugPrint ( ' No matching message found for ACK: $ ackHashHex ' ) ;
}
2025-12-26 11:42:02 -07:00
}
}
2026-03-23 08:14:46 -07:00
String ? getContactKeyForAckHash ( int ackHash ) {
2025-12-26 11:42:02 -07:00
for ( var entry in _pendingMessages . entries ) {
final message = entry . value ;
if ( message . expectedAckHash ! = null & &
2026-03-21 13:01:02 -07:00
message . expectedAckHash = = ackHash ) {
2025-12-26 11:42:02 -07:00
final contact = _pendingContacts [ entry . key ] ;
return contact ? . publicKeyHex ;
}
}
return null ;
}
int calculateDefaultTimeout ( Contact contact ) {
if ( contact . pathLength < 0 ) {
return 15000 ;
} else {
return 3000 + ( 3000 * contact . pathLength ) ;
}
}
void _recordPathResultFromMessage (
String contactKey ,
Message message ,
PathSelection ? selection ,
bool success ,
int ? tripTimeMs ,
) {
2026-03-20 01:54:31 -07:00
final callback = _config ? . recordPathResult ;
if ( callback = = null ) return ;
2025-12-26 11:42:02 -07:00
final recordSelection = selection ? ? _selectionFromMessage ( message ) ;
if ( recordSelection = = null ) return ;
2026-03-20 01:54:31 -07:00
callback ( contactKey , recordSelection , success , tripTimeMs ) ;
2025-12-26 11:42:02 -07:00
}
PathSelection ? _selectionFromMessage ( Message message ) {
2025-12-30 20:04:53 -07:00
if ( message . pathLength ! = null & & message . pathLength ! < 0 ) {
2025-12-26 11:42:02 -07:00
return const PathSelection ( pathBytes: [ ] , hopCount: - 1 , useFlood: true ) ;
}
if ( message . pathBytes . isEmpty & & message . pathLength = = null ) {
return null ;
}
return PathSelection (
pathBytes: message . pathBytes ,
hopCount: message . pathLength ? ? message . pathBytes . length ,
useFlood: false ,
) ;
}
@ override
void dispose ( ) {
for ( var timer in _timeoutTimers . values ) {
timer . cancel ( ) ;
}
_timeoutTimers . clear ( ) ;
_pendingMessages . clear ( ) ;
_pendingContacts . clear ( ) ;
2026-03-20 01:54:31 -07:00
_attemptPathHistory . clear ( ) ;
2025-12-30 21:42:14 -07:00
_expectedAckHashes . clear ( ) ;
_ackHistory . clear ( ) ;
2025-12-31 22:19:48 -07:00
_ackHashToMessageId . clear ( ) ;
2026-03-14 09:33:37 -07:00
_sendQueue . clear ( ) ;
_activeMessages . clear ( ) ;
_resolvedMessages . clear ( ) ;
2025-12-26 11:42:02 -07:00
super . dispose ( ) ;
}
}