Limit direct payload hops

This commit is contained in:
Janez T
2026-03-05 08:30:06 +01:00
parent 5beac70644
commit c83eaa4d98
7 changed files with 172 additions and 11 deletions

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000228"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000191">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.45931"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.404227">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="102.765046"> <testcase classname="fastlane.lanes" name="2: build_app" time="99.61383">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="41.615863"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="229.815554">
</testcase> </testcase>

View File

@@ -20,6 +20,7 @@ import '../utils/image_message_parser.dart';
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3;
final ConnectionProvider connectionProvider; final ConnectionProvider connectionProvider;
final ContactsProvider contactsProvider; final ContactsProvider contactsProvider;
final MessagesProvider messagesProvider; final MessagesProvider messagesProvider;
@@ -565,6 +566,22 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found', '⚠️ [AppProvider] Voice fetch requester contact not found',
); );
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return; return;
} }
unawaited( unawaited(
@@ -665,6 +682,17 @@ class AppProvider with ChangeNotifier {
senderPrefix, senderPrefix,
); );
if (requester != null) { if (requester != null) {
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited( unawaited(
imageProvider.serveSessionTo( imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId, sessionId: imageFetchRequest.sessionId,
@@ -674,6 +702,15 @@ class AppProvider with ChangeNotifier {
: null, : null,
), ),
); );
} else {
debugPrint(
'⚠️ [AppProvider] Image fetch requester contact not found',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
} }
} }
} }

View File

@@ -36,6 +36,7 @@ class ImageSession {
class ImageProvider with ChangeNotifier { class ImageProvider with ChangeNotifier {
static const String _storageKey = 'stored_image_sessions_v1'; static const String _storageKey = 'stored_image_sessions_v1';
static const Duration _outgoingTtl = Duration(minutes: 15); static const Duration _outgoingTtl = Duration(minutes: 15);
static const int maxDirectPayloadHops = 3;
/// Incoming sessions keyed by sessionId. /// Incoming sessions keyed by sessionId.
final Map<String, ImageSession> _sessions = {}; final Map<String, ImageSession> _sessions = {};
@@ -190,6 +191,12 @@ class ImageProvider with ChangeNotifier {
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path'); debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
return false; return false;
} }
if (requester.outPathLen > maxDirectPayloadHops) {
debugPrint(
'⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
);
return false;
}
for (final fragment in cached.fragments) { for (final fragment in cached.fragments) {
if (requestedIndices != null && if (requestedIndices != null &&

View File

@@ -36,6 +36,7 @@ class VoiceSession {
/// Manages incoming voice packet sessions and coordinates playback. /// Manages incoming voice packet sessions and coordinates playback.
class VoiceProvider with ChangeNotifier { class VoiceProvider with ChangeNotifier {
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1'; static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
static const int maxDirectPayloadHops = 3;
final VoiceCodecService _codec; final VoiceCodecService _codec;
final VoicePlayerService _player; final VoicePlayerService _player;
late final StreamSubscription<void> _playerEventsSub; late final StreamSubscription<void> _playerEventsSub;
@@ -161,6 +162,12 @@ class VoiceProvider with ChangeNotifier {
); );
return false; return false;
} }
if (requester.outPathLen > maxDirectPayloadHops) {
debugPrint(
'⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
);
return false;
}
for (final packet in cached.packets) { for (final packet in cached.packets) {
if (requestedIndices != null && if (requestedIndices != null &&

View File

@@ -434,6 +434,8 @@ class _MessagesTabState extends State<MessagesTab> {
ImageSource source = ImageSource.gallery, ImageSource source = ImageSource.gallery,
}) async { }) async {
if (_isSendingImage) return; if (_isSendingImage) return;
final shouldContinue = await _confirmPublicChannelMediaSend('image');
if (!shouldContinue) return;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) { if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device'); ToastLogger.error(context, 'Not connected to device');

View File

@@ -31,6 +31,7 @@ class ImageMessageBubble extends StatefulWidget {
} }
class _ImageMessageBubbleState extends State<ImageMessageBubble> { class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static const int _maxFetchHops = 3;
bool _isRequesting = false; bool _isRequesting = false;
String? _errorText; String? _errorText;
Timer? _requestTimeoutTimer; Timer? _requestTimeoutTimer;
@@ -208,15 +209,41 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
sender = _resolveSender(envelope); sender = _resolveSender(envelope);
} }
if (sender == null) { if (sender == null) {
setState(() => _errorText = 'Sender not reachable'); await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
);
return; return;
} }
if (sender.outPathLen < 0) {
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (sender.outPathLen > _maxFetchHops) {
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
);
return;
}
if (sender.outPathLen >= 2) {
_showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.',
);
}
setState(() => _errorText = null);
final conn = context.read<ConnectionProvider>(); final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>(); final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey; final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
setState(() => _errorText = 'Device key unavailable'); await _showBlockingAlert(
'Cannot fetch image',
'Device key is unavailable.',
);
return; return;
} }
@@ -227,8 +254,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
// If we already have some fragments, request only what's missing. // If we already have some fragments, request only what's missing.
final missing = imageProvider.missingFragmentIndices(envelope.sessionId); final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
final isPartialResume = missing.isNotEmpty && final isPartialResume =
missing.length < envelope.total; missing.isNotEmpty && missing.length < envelope.total;
final request = isPartialResume final request = isPartialResume
? ImageFetchRequest( ? ImageFetchRequest(
sessionId: envelope.sessionId, sessionId: envelope.sessionId,
@@ -276,7 +303,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_requestTimeoutTimer = TransferTimeout.start( _requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate, txEstimate: txEstimate,
onTimeout: () { onTimeout: () {
if (mounted && _isRequesting && !imageProvider.isComplete(envelope.sessionId)) { if (mounted &&
_isRequesting &&
!imageProvider.isComplete(envelope.sessionId)) {
setState(() => _isRequesting = false); setState(() => _isRequesting = false);
} }
}, },
@@ -309,6 +338,30 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return null; return null;
} }
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
);
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
),
);
}
static String _statusText({ static String _statusText({
required bool isComplete, required bool isComplete,
required bool isRequesting, required bool isRequesting,

View File

@@ -27,6 +27,7 @@ class VoiceMessageBubble extends StatefulWidget {
} }
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> { class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
static const int _maxFetchHops = 3;
bool _isRequesting = false; bool _isRequesting = false;
bool _autoPlayWhenReady = false; bool _autoPlayWhenReady = false;
String? _errorText; String? _errorText;
@@ -207,14 +208,44 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
sender = _resolveSenderContact(); sender = _resolveSenderContact();
} }
if (sender == null) { if (sender == null) {
_setUnavailable(); await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
);
return; return;
} }
if (sender.outPathLen < 0) {
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (sender.outPathLen > _maxFetchHops) {
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
);
return;
}
if (sender.outPathLen >= 2) {
_showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.',
);
}
if (!mounted) return;
setState(() {
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final deviceKey = connectionProvider.deviceInfo.publicKey; final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) { if (deviceKey == null || deviceKey.length < 6) {
_setUnavailable(); await _showBlockingAlert(
'Cannot fetch voice',
'Device key is unavailable.',
);
return; return;
} }
@@ -307,6 +338,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return null; return null;
} }
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
);
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
),
);
}
static String _formatDuration(double seconds) { static String _formatDuration(double seconds) {
final s = seconds.round(); final s = seconds.round();
if (s < 60) return '${s}s'; if (s < 60) return '${s}s';