diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..53d8197 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,52 @@ +name: Build APK + +on: + push: + workflow_dispatch: + +jobs: + build: + name: Build obfuscated release APK + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.1' + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + # --obfuscate scrambles Dart symbol names in the AOT snapshot; + # --split-debug-info writes the symbol maps needed to de-obfuscate + # crash stack traces (flutter symbolize). Without those maps a release + # crash is unreadable, so they are uploaded as a separate artifact. + - name: Build obfuscated release APK + run: flutter build apk --release --obfuscate --split-debug-info=build/symbols + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: seeonce-release-apk + path: build/app/outputs/flutter-apk/app-release.apk + if-no-files-found: error + + - name: Upload de-obfuscation symbols + uses: actions/upload-artifact@v4 + with: + name: obfuscation-symbols + path: build/symbols + if-no-files-found: error diff --git a/CLAUDE.md b/CLAUDE.md index 83a8f5f..77077b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,8 @@ Senders compress → encrypt → chunk → send: `image_sender` (+ `image_compre Handshake/wire changes require both devices to run a matching build and may force re-pairing. Existing on-wire choices that are load-bearing: RSA-OAEP uses SHA-256 (not the SHA-1 default), reconnect key-hash IDs are the full 256-bit hash, and reconnect includes an `auth_challenge`/`auth_response` step. Changing any of these is a breaking protocol change. +The reconnect auth handshake is **v2** (`_authProtocolVersion` in `local_reconnect_service.dart`). v2 signs `nonce || channelBinding` rather than the bare nonce, where `channelBinding` is a SHA-256 over the two DTLS certificate fingerprints (sorted) pulled from the local/remote SDP. This binds the proof-of-key-possession to the specific DTLS connection, so a LAN relay/MITM — which must terminate DTLS with its own certificate on each leg — is detected (the fingerprints, and therefore the binding, won't match). v2 peers refuse to authenticate with v1 peers, so upgrading one side forces a re-pair. Bump the version on any further change to the signed material. + ## Reference `IMPLEMENTATION_PLAN.md` (repo root) and the "Security notes" section of `README.md` contain deeper rationale for the trust model, platform differences, and ephemerality guarantees. diff --git a/android/.gitignore b/android/.gitignore index be3943c..edbce6f 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,4 +1,5 @@ gradle-wrapper.jar +/build/ /.gradle /captures/ /gradlew diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html deleted file mode 100644 index ac74da0..0000000 --- a/android/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..15e908f Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..9342730 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..c544be4 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..a32e2b8 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/lib/app.dart b/lib/app.dart index 54a1f53..c9c3803 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:no_screenshot/no_screenshot.dart'; +import 'core/theme.dart'; + import 'crypto/key_store.dart'; import 'data/repositories/peer_repository.dart'; import 'messaging/incoming_message_router.dart'; +import 'notifications/notification_service.dart'; import 'rtc/connection_supervisor.dart'; import 'rtc/local_reconnect_service.dart'; import 'services/background_service.dart'; @@ -138,6 +140,15 @@ class _SeeOnceAppState extends ConsumerState await BackgroundService.instance.requestPermissions(); await BackgroundService.instance.start(); } + + // Tapping a message notification opens that peer's conversation — both + // while running and when the tap cold-launched the app. + NotificationService.instance.onNotificationTap = + (peerId) => _router.push('/conversation/$peerId'); + final launchPeerId = await NotificationService.instance.getLaunchPeerId(); + if (launchPeerId != null && peers.any((p) => p.id == launchPeerId)) { + _router.push('/conversation/$launchPeerId'); + } } @override @@ -145,35 +156,7 @@ class _SeeOnceAppState extends ConsumerState return MaterialApp.router( title: 'SeeOnce', themeMode: ThemeMode.dark, - darkTheme: ThemeData( - brightness: Brightness.dark, - scaffoldBackgroundColor: const Color(0xFF0A0A0F), - colorScheme: const ColorScheme.dark( - surface: Color(0xFF12121A), - primary: Color(0xFF7B61FF), // Accent - secondary: Color(0xFF9B89FF), - onSurface: Color(0xFFF1F1F7), - error: Color(0xFFEF4444), - ), - // Bundle Noto color-emoji as a fallback so emoji render the same on - // every device — older Androids lack newer glyphs and showed boxes. - textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme) - .apply(fontFamilyFallback: const ['NotoColorEmoji']).copyWith( - bodyMedium: const TextStyle( - color: Color(0xFFF1F1F7), - fontFamilyFallback: ['NotoColorEmoji']), - bodySmall: const TextStyle( - color: Color(0xFF9090A0), - fontFamilyFallback: ['NotoColorEmoji']), - ), - snackBarTheme: const SnackBarThemeData( - behavior: SnackBarBehavior.floating, - ), - appBarTheme: const AppBarTheme( - backgroundColor: Color(0xFF0A0A0F), - elevation: 0, - ), - ), + darkTheme: buildAppTheme(), routerConfig: _router, // Overlay a setup screen until bootstrap finishes. MaterialApp.builder // sits below the app's Directionality/MediaQuery, so a plain widget here @@ -205,7 +188,7 @@ class _SetupScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFF0A0A0F), + backgroundColor: AppColors.background, body: Center( child: Padding( padding: const EdgeInsets.all(32), @@ -217,27 +200,28 @@ class _SetupScreen extends StatelessWidget { const SizedBox(height: 24), const Text( 'Setting up encryption…', - style: TextStyle(color: Color(0xFFF1F1F7), fontSize: 16), + style: TextStyle(color: AppColors.textPrimary, fontSize: 16), ), const SizedBox(height: 8), const Text( 'This only happens once and may take a moment.', textAlign: TextAlign.center, - style: TextStyle(color: Color(0xFF9090A0), fontSize: 13), + style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ] else ...[ const Icon(Icons.error_outline, - color: Color(0xFFEF4444), size: 40), + color: AppColors.error, size: 40), const SizedBox(height: 16), const Text( 'Setup failed', - style: TextStyle(color: Color(0xFFF1F1F7), fontSize: 16), + style: TextStyle(color: AppColors.textPrimary, fontSize: 16), ), const SizedBox(height: 8), Text( error!, textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF9090A0), fontSize: 12), + style: const TextStyle( + color: AppColors.textMuted, fontSize: 12), ), ], ], diff --git a/lib/core/constants.dart b/lib/core/constants.dart index ab1c2a4..4f73b50 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -1,5 +1,13 @@ class AppConstants { static const String stunServer = 'stun:stun.l.google.com:19302'; + + /// Fallback STUN servers: if the primary is unreachable (blocked port, + /// regional outage) srflx candidates can still be gathered for pairing. + static const List stunServers = [ + stunServer, + 'stun:stun1.l.google.com:19302', + 'stun:stun2.l.google.com:19302', + ]; static const int dataChannelChunkSize = 16384; // 16 KB per chunk static const String dataChannelLabel = 'seeonce-data'; static const String controlChannelLabel = 'seeonce-control'; diff --git a/lib/core/theme.dart b/lib/core/theme.dart new file mode 100644 index 0000000..c65474f --- /dev/null +++ b/lib/core/theme.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +/// Central color + theme definitions for SeeOnce. Keeping these in one place +/// (rather than scattered `Color(0x..)` / `Colors.grey` literals across +/// widgets) keeps contrast consistent and WCAG-compliant on the dark surface. +class AppColors { + AppColors._(); + + // Surfaces, darkest → lightest. + static const background = Color(0xFF0A0A0F); + static const surface = Color(0xFF12121A); + static const surfaceContainer = Color(0xFF1B1B26); + static const surfaceContainerHigh = Color(0xFF232330); + + // Brand accent. + static const primary = Color(0xFF8C74FF); // brightened for better contrast + static const primaryMuted = Color(0xFF9B89FF); + + // Text. `textPrimary` ~15:1 and `textMuted` ~6.3:1 against [background], + // both clearing WCAG AA for their text sizes. + static const textPrimary = Color(0xFFF1F1F7); + static const textMuted = Color(0xFFA6A6B8); + + static const outline = Color(0xFF2C2C3A); + + // Status. + static const online = Color(0xFF34D399); // accessible green (vs Colors.green) + static const reconnecting = Color(0xFFF59E0B); // amber, ~8:1 on background + static const offline = Color(0xFF6B6B7B); + static const error = Color(0xFFEF4444); +} + +/// Builds the app's dark theme. Text uses the locally-bundled Inter family +/// (no runtime network fetch) with NotoColorEmoji as a glyph fallback so emoji +/// render everywhere; because Inter covers Latin + digits, the fallback is only +/// consulted for emoji and never corrupts ordinary text. +ThemeData buildAppTheme() { + const fontFallback = ['NotoColorEmoji']; + + final base = ThemeData( + brightness: Brightness.dark, + useMaterial3: true, + fontFamily: 'Inter', + scaffoldBackgroundColor: AppColors.background, + colorScheme: const ColorScheme.dark( + surface: AppColors.surface, + surfaceContainerHighest: AppColors.surfaceContainerHigh, + surfaceContainerHigh: AppColors.surfaceContainer, + primary: AppColors.primary, + onPrimary: Color(0xFF15032E), + secondary: AppColors.primaryMuted, + onSecondary: Color(0xFF15032E), + onSurface: AppColors.textPrimary, + onSurfaceVariant: AppColors.textMuted, + outline: AppColors.outline, + error: AppColors.error, + onError: Colors.white, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, + elevation: 0, + centerTitle: false, + ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: AppColors.surfaceContainerHigh, + contentTextStyle: TextStyle(color: AppColors.textPrimary), + ), + dividerTheme: const DividerThemeData(color: AppColors.outline, space: 1), + listTileTheme: const ListTileThemeData( + iconColor: AppColors.textMuted, + textColor: AppColors.textPrimary, + ), + ); + + return base.copyWith( + textTheme: base.textTheme + .apply( + fontFamily: 'Inter', + fontFamilyFallback: fontFallback, + bodyColor: AppColors.textPrimary, + displayColor: AppColors.textPrimary, + ) + .copyWith( + bodySmall: base.textTheme.bodySmall?.copyWith( + color: AppColors.textMuted, + fontFamilyFallback: fontFallback, + ), + ), + ); +} diff --git a/lib/data/repositories/settings_repository.dart b/lib/data/repositories/settings_repository.dart index ca55366..d0e064e 100644 --- a/lib/data/repositories/settings_repository.dart +++ b/lib/data/repositories/settings_repository.dart @@ -16,15 +16,32 @@ class AppSettings { final CompressionSettings compression; final bool notificationsEnabled; + /// Name shown to peers when pairing. Empty means "not set" — a random + /// fallback is generated at pairing time. + final String displayName; + const AppSettings({ required this.compression, required this.notificationsEnabled, + this.displayName = '', }); + + AppSettings copyWith({ + CompressionSettings? compression, + bool? notificationsEnabled, + String? displayName, + }) => + AppSettings( + compression: compression ?? this.compression, + notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, + displayName: displayName ?? this.displayName, + ); } class SettingsRepository { static const String _compressionKey = 'compression_settings'; static const String _notificationsKey = 'notifications_enabled'; + static const String _displayNameKey = 'display_name'; Future getSettings() async { final box = Hive.box(AppConstants.settingsBoxName); @@ -37,10 +54,12 @@ class SettingsRepository { final notificationsEnabled = box.get(_notificationsKey, defaultValue: true) as bool; + final displayName = box.get(_displayNameKey, defaultValue: '') as String; return AppSettings( compression: compression, notificationsEnabled: notificationsEnabled, + displayName: displayName, ); } @@ -51,5 +70,6 @@ class SettingsRepository { 'jpegQuality': settings.compression.jpegQuality, }); await box.put(_notificationsKey, settings.notificationsEnabled); + await box.put(_displayNameKey, settings.displayName); } } diff --git a/lib/features/conversation/providers/conversation_provider.dart b/lib/features/conversation/providers/conversation_provider.dart index 4aceab2..ebf5e55 100644 --- a/lib/features/conversation/providers/conversation_provider.dart +++ b/lib/features/conversation/providers/conversation_provider.dart @@ -162,12 +162,18 @@ class ConversationNotifier extends StateNotifier { final peerRepo = ref.read(peerRepositoryProvider); await peerRepo.deletePeer(remotePeerId); - // 3. Clear messages (images + videos + text) + // 3. Clear messages (images + videos + text), zero-filling decrypted media + // bytes on the way out — dropping the references alone would leave the + // plaintext in memory until the GC happens to reclaim it. final pending = Map>.from(state.pendingByPeer); - pending.remove(remotePeerId); + for (final m in pending.remove(remotePeerId) ?? const []) { + m.imageBytes.fillRange(0, m.imageBytes.length, 0); + } final videos = Map>.from(state.pendingVideosByPeer); - videos.remove(remotePeerId); + for (final m in videos.remove(remotePeerId) ?? const []) { + m.videoBytes.fillRange(0, m.videoBytes.length, 0); + } final texts = Map>.from(state.messagesByPeer); texts.remove(remotePeerId); state = state.copyWith( diff --git a/lib/features/conversation/screens/conversation_screen.dart b/lib/features/conversation/screens/conversation_screen.dart index 421af80..1739269 100644 --- a/lib/features/conversation/screens/conversation_screen.dart +++ b/lib/features/conversation/screens/conversation_screen.dart @@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart'; import '../../../core/constants.dart'; import '../../../core/errors.dart'; +import '../../../core/theme.dart'; import '../../../data/models/image_message.dart'; import '../../../data/models/peer.dart'; import '../../../data/models/text_message.dart'; @@ -419,8 +420,10 @@ class _ConversationScreenState extends ConsumerState const SizedBox(width: 8), Icon(Icons.circle, color: isOnline - ? Colors.green - : (isReconnecting ? Colors.orange : Colors.grey), + ? AppColors.online + : (isReconnecting + ? AppColors.reconnecting + : AppColors.offline), size: 10), ], ), @@ -441,21 +444,21 @@ class _ConversationScreenState extends ConsumerState if (!isOnline) Container( width: double.infinity, - color: Colors.orange.withAlpha(40), + color: AppColors.reconnecting.withAlpha(38), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: isReconnecting - ? Row( - children: const [ + ? const Row( + children: [ SizedBox( width: 14, height: 14, child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.orange), + strokeWidth: 2, color: AppColors.reconnecting), ), SizedBox(width: 10), Text('Reconnecting…', - style: TextStyle(color: Colors.orange)), + style: TextStyle(color: AppColors.reconnecting)), ], ) : Row( @@ -463,16 +466,16 @@ class _ConversationScreenState extends ConsumerState const Expanded( child: Text( 'Peer is offline.', - style: TextStyle(color: Colors.orange), + style: TextStyle(color: AppColors.reconnecting), ), ), TextButton.icon( onPressed: () => LocalReconnectService.instance .reconnectPeer(_peer!), icon: const Icon(Icons.wifi_protected_setup, - size: 18, color: Colors.orange), + size: 18, color: AppColors.reconnecting), label: const Text('Reconnect', - style: TextStyle(color: Colors.orange)), + style: TextStyle(color: AppColors.reconnecting)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: const Size(0, 32), @@ -484,18 +487,29 @@ class _ConversationScreenState extends ConsumerState ), Container( width: double.infinity, - color: const Color(0xFF7B61FF).withAlpha(25), + color: AppColors.primary.withAlpha(30), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: const Text( - 'Messages are encrypted and disappear 30 minutes after they are sent.', - style: TextStyle(color: Color(0xFF9B89FF), fontSize: 12), + child: Row( + children: [ + const Icon(Icons.lock_outline, + size: 14, color: AppColors.primaryMuted), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Messages are encrypted and disappear 30 minutes after they are sent.', + style: TextStyle( + color: AppColors.primaryMuted.withAlpha(230), + fontSize: 12), + ), + ), + ], ), ), Expanded( child: timeline.isEmpty ? const Center( child: Text('No messages yet.', - style: TextStyle(color: Colors.grey))) + style: TextStyle(color: AppColors.textMuted))) : ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(8), @@ -538,7 +552,10 @@ class _ConversationScreenState extends ConsumerState IconButton( tooltip: 'Send', onPressed: isOnline ? _sendText : null, - icon: const Icon(Icons.send, color: Color(0xFF7B61FF)), + icon: Icon(Icons.send, + color: isOnline + ? AppColors.primary + : AppColors.offline), ), ], ), @@ -574,9 +591,10 @@ class _ConversationScreenState extends ConsumerState Widget _videoTile(VideoMessage msg) { final secs = (msg.durationMs / 1000).round(); return Card( + color: AppColors.surfaceContainerHigh, margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( - leading: const Icon(Icons.play_circle_fill, color: Color(0xFF7B61FF)), + leading: const Icon(Icons.play_circle_fill, color: AppColors.primary), title: const Text('Tap to view video'), subtitle: Text(secs > 0 ? 'Video · ${secs}s · view once' : 'View once'), onTap: () { @@ -591,9 +609,10 @@ class _ConversationScreenState extends ConsumerState Widget _imageTile(ImageMessage msg) { return Card( + color: AppColors.surfaceContainerHigh, margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( - leading: const Icon(Icons.image, color: Color(0xFF7B61FF)), + leading: const Icon(Icons.image, color: AppColors.primary), title: const Text('Tap to view image'), subtitle: Text( 'Received ${msg.receivedAt.toLocal().toString().split('.')[0]}', @@ -612,6 +631,13 @@ class _ConversationScreenState extends ConsumerState final local = msg.localAt.toLocal(); final hh = local.hour.toString().padLeft(2, '0'); final mm = local.minute.toString().padLeft(2, '0'); + // Distinct bubble surfaces with adequate contrast: the received bubble was + // 0xFF12121A, all but invisible against the 0xFF0A0A0F background. + final bubbleColor = + msg.isMine ? AppColors.primary : AppColors.surfaceContainerHigh; + final textColor = msg.isMine ? Colors.white : AppColors.textPrimary; + final timeColor = + msg.isMine ? Colors.white.withAlpha(190) : AppColors.textMuted; return Align( alignment: msg.isMine ? Alignment.centerRight : Alignment.centerLeft, child: Container( @@ -619,22 +645,24 @@ class _ConversationScreenState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: const BoxConstraints(maxWidth: 280), decoration: BoxDecoration( - color: msg.isMine - ? const Color(0xFF7B61FF) - : const Color(0xFF12121A), - borderRadius: BorderRadius.circular(14), + color: bubbleColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(msg.isMine ? 16 : 4), + bottomRight: Radius.circular(msg.isMine ? 4 : 16), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText(msg.text, - style: const TextStyle( - color: Color(0xFFF1F1F7), - fontFamilyFallback: ['NotoColorEmoji'])), + style: TextStyle( + color: textColor, + fontFamilyFallback: const ['NotoColorEmoji'])), const SizedBox(height: 2), Text('$hh:$mm', - style: const TextStyle( - color: Color(0xFF9090A0), fontSize: 10)), + style: TextStyle(color: timeColor, fontSize: 10)), ], ), ), diff --git a/lib/features/conversation/screens/image_viewer_screen.dart b/lib/features/conversation/screens/image_viewer_screen.dart index 118ad7f..16e8edb 100644 --- a/lib/features/conversation/screens/image_viewer_screen.dart +++ b/lib/features/conversation/screens/image_viewer_screen.dart @@ -20,6 +20,8 @@ class ImageViewerScreen extends ConsumerStatefulWidget { } class _ImageViewerScreenState extends ConsumerState with WidgetsBindingObserver { + bool _dismissed = false; + @override void initState() { super.initState(); @@ -34,17 +36,23 @@ class _ImageViewerScreenState extends ConsumerState with Widg @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { - if (mounted) { - // Auto-dismiss when app is backgrounded — mark viewed so the tile is removed. - ref - .read(conversationProvider.notifier) - .markAsViewed(widget.peerId, widget.message.messageId); - context.pop(); - } + // Only a genuine background (`paused`) consumes the view. `inactive` fires + // transiently during route transitions / control-centre peeks, which was + // consuming the image the instant it opened (same fix as the video viewer). + if (state == AppLifecycleState.paused) { + _markViewedAndPop(); } } + void _markViewedAndPop() { + if (_dismissed || !mounted) return; + _dismissed = true; + ref + .read(conversationProvider.notifier) + .markAsViewed(widget.peerId, widget.message.messageId); + context.pop(); + } + Future _onWillPop() async { return await showDialog( context: context, @@ -67,15 +75,9 @@ class _ImageViewerScreenState extends ConsumerState with Widg Future _dismissImage() async { final shouldClose = await _onWillPop(); - if (shouldClose && mounted) { - // Mark viewed (removes message from state) before popping so the - // conversation screen rebuilds without the tile immediately. - ref - .read(conversationProvider.notifier) - .markAsViewed(widget.peerId, widget.message.messageId); - // ignore: use_build_context_synchronously - context.pop(); - } + // Mark viewed (removes message from state) before popping so the + // conversation screen rebuilds without the tile immediately. + if (shouldClose) _markViewedAndPop(); } @override diff --git a/lib/features/pairing/providers/pairing_provider.dart b/lib/features/pairing/providers/pairing_provider.dart index 866abee..dd8666b 100644 --- a/lib/features/pairing/providers/pairing_provider.dart +++ b/lib/features/pairing/providers/pairing_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../../../core/errors.dart'; +import '../../../data/repositories/settings_repository.dart'; import '../../../services/background_service.dart'; import '../../../rtc/key_exchange_handler.dart'; import '../../../rtc/peer_connection_pool.dart'; @@ -81,6 +82,16 @@ class PairingNotifier extends StateNotifier { PairingNotifier(this.ref) : super(const PairingState()); + /// The name this device announces during key exchange: the user's configured + /// device name, or a short random fallback when none is set. + Future _ownDisplayName() async { + try { + final settings = await SettingsRepository().getSettings(); + if (settings.displayName.isNotEmpty) return settings.displayName; + } catch (_) {} + return 'Me_${const Uuid().v4().substring(0, 4)}'; + } + /// Turns an exception into a short, user-presentable message without leaking /// internal/platform details or stack traces. String _friendly(Object e) { @@ -105,7 +116,7 @@ class PairingNotifier extends StateNotifier { ); try { final ownPeerId = const Uuid().v4(); - final displayName = 'Me_${const Uuid().v4().substring(0, 4)}'; + final displayName = await _ownDisplayName(); _kex = KeyExchangeHandler( ownPeerId: ownPeerId, ownDisplayName: displayName); @@ -175,7 +186,7 @@ class PairingNotifier extends StateNotifier { ); try { final ownPeerId = const Uuid().v4(); - final displayName = 'Me_${const Uuid().v4().substring(0, 4)}'; + final displayName = await _ownDisplayName(); _kex = KeyExchangeHandler( ownPeerId: ownPeerId, ownDisplayName: displayName); diff --git a/lib/features/pairing/screens/pairing_screen.dart b/lib/features/pairing/screens/pairing_screen.dart index 3f6297b..d9b0cb9 100644 --- a/lib/features/pairing/screens/pairing_screen.dart +++ b/lib/features/pairing/screens/pairing_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/theme.dart'; import '../providers/pairing_provider.dart'; class PairingScreen extends ConsumerWidget { @@ -19,7 +20,7 @@ class PairingScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Icon(Icons.devices, size: 72, color: Color(0xFF7B61FF)), + const Icon(Icons.devices, size: 72, color: AppColors.primary), const SizedBox(height: 8), const Text( 'Connect with a contact', @@ -30,7 +31,7 @@ class PairingScreen extends ConsumerWidget { const Text( 'You\'ll exchange short codes to securely pair devices.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 14), + style: TextStyle(color: AppColors.textMuted, fontSize: 14), ), const SizedBox(height: 40), diff --git a/lib/features/peers/screens/peers_screen.dart b/lib/features/peers/screens/peers_screen.dart index 093ceaf..28fc766 100644 --- a/lib/features/peers/screens/peers_screen.dart +++ b/lib/features/peers/screens/peers_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../providers/peers_provider.dart'; +import '../../../core/theme.dart'; import '../../../widgets/peer_tile.dart'; class PeersScreen extends ConsumerWidget { @@ -25,13 +26,25 @@ class PeersScreen extends ConsumerWidget { data: (peers) { if (peers.isEmpty) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.group_add, size: 64, color: Colors.grey), - const SizedBox(height: 16), - const Text('No peers yet. Tap + to pair.', style: TextStyle(color: Colors.grey)), - ], + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.group_add, + size: 64, color: AppColors.primaryMuted), + const SizedBox(height: 16), + const Text('No contacts yet', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + const Text( + 'Tap + to pair with someone in person.', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.textMuted), + ), + ], + ), ), ); } diff --git a/lib/features/settings/providers/settings_provider.dart b/lib/features/settings/providers/settings_provider.dart index 778ee97..5602451 100644 --- a/lib/features/settings/providers/settings_provider.dart +++ b/lib/features/settings/providers/settings_provider.dart @@ -23,12 +23,11 @@ class SettingsNotifier extends StateNotifier> { }) async { final current = state.valueOrNull; if (current == null) return; - final updated = AppSettings( + final updated = current.copyWith( compression: CompressionSettings( maxDimension: maxDimension, jpegQuality: jpegQuality, ), - notificationsEnabled: current.notificationsEnabled, ); await _repo.saveSettings(updated); state = AsyncValue.data(updated); @@ -37,10 +36,15 @@ class SettingsNotifier extends StateNotifier> { Future setNotificationsEnabled(bool enabled) async { final current = state.valueOrNull; if (current == null) return; - final updated = AppSettings( - compression: current.compression, - notificationsEnabled: enabled, - ); + final updated = current.copyWith(notificationsEnabled: enabled); + await _repo.saveSettings(updated); + state = AsyncValue.data(updated); + } + + Future setDisplayName(String name) async { + final current = state.valueOrNull; + if (current == null) return; + final updated = current.copyWith(displayName: name.trim()); await _repo.saveSettings(updated); state = AsyncValue.data(updated); } diff --git a/lib/features/settings/screens/settings_screen.dart b/lib/features/settings/screens/settings_screen.dart index 84bc306..c7ccc54 100644 --- a/lib/features/settings/screens/settings_screen.dart +++ b/lib/features/settings/screens/settings_screen.dart @@ -13,6 +13,9 @@ class _SettingsScreenState extends State { final _repo = SettingsRepository(); AppSettings? _settings; + /// Sensible photo long-edge presets; larger = sharper but slower to send. + static const _dimensionChoices = [720, 1080, 1440, 2160]; + @override void initState() { super.initState(); @@ -21,7 +24,118 @@ class _SettingsScreenState extends State { Future _loadSettings() async { final s = await _repo.getSettings(); - setState(() => _settings = s); + if (mounted) setState(() => _settings = s); + } + + Future _save(AppSettings updated) async { + await _repo.saveSettings(updated); + if (mounted) setState(() => _settings = updated); + } + + Future _editDisplayName() async { + final controller = TextEditingController(text: _settings!.displayName); + final name = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Device name'), + content: TextField( + controller: controller, + autofocus: true, + maxLength: 30, + decoration: const InputDecoration( + hintText: 'Shown to contacts when pairing', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('Save')), + ], + ), + ); + controller.dispose(); + if (name == null) return; + await _save(_settings!.copyWith(displayName: name)); + } + + Future _pickMaxDimension() async { + final current = _settings!.compression.maxDimension; + final picked = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Max image dimension'), + children: [ + RadioGroup( + groupValue: current, + onChanged: (v) => Navigator.pop(ctx, v), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final d in _dimensionChoices) + RadioListTile(title: Text('$d px'), value: d), + ], + ), + ), + ], + ), + ); + if (picked == null) return; + await _save(_settings!.copyWith( + compression: CompressionSettings( + maxDimension: picked, + jpegQuality: _settings!.compression.jpegQuality, + ), + )); + } + + Future _pickJpegQuality() async { + var quality = _settings!.compression.jpegQuality; + final picked = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: const Text('JPEG quality'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$quality%', + style: Theme.of(ctx).textTheme.headlineSmall), + Slider( + value: quality.toDouble(), + min: 40, + max: 100, + divisions: 12, + label: '$quality%', + onChanged: (v) => + setDialogState(() => quality = v.round()), + ), + const Text( + 'Higher quality means larger transfers.', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, quality), + child: const Text('Save')), + ], + ), + ), + ); + if (picked == null) return; + await _save(_settings!.copyWith( + compression: CompressionSettings( + maxDimension: _settings!.compression.maxDimension, + jpegQuality: picked, + ), + )); } @override @@ -33,6 +147,8 @@ class _SettingsScreenState extends State { ); } + final displayName = _settings!.displayName; + return Scaffold( appBar: AppBar(title: const Text('Settings')), body: SettingsList( @@ -40,22 +156,33 @@ class _SettingsScreenState extends State { settingsListBackground: Theme.of(context).scaffoldBackgroundColor, ), sections: [ + SettingsSection( + title: const Text('Profile'), + tiles: [ + SettingsTile.navigation( + leading: const Icon(Icons.badge_outlined), + title: const Text('Device Name'), + value: Text(displayName.isEmpty ? 'Not set' : displayName), + description: const Text( + 'Shown to contacts when you pair with them.'), + onPressed: (_) => _editDisplayName(), + ), + ], + ), SettingsSection( title: const Text('Image Compression'), tiles: [ SettingsTile.navigation( + leading: const Icon(Icons.photo_size_select_large), title: const Text('Max Dimension'), value: Text('${_settings!.compression.maxDimension} px'), - onPressed: (context) { - // In a full app, show a dialog to change this - }, + onPressed: (_) => _pickMaxDimension(), ), SettingsTile.navigation( + leading: const Icon(Icons.high_quality_outlined), title: const Text('JPEG Quality'), value: Text('${_settings!.compression.jpegQuality}%'), - onPressed: (context) { - // In a full app, show a dialog to change this - }, + onPressed: (_) => _pickJpegQuality(), ), ], ), @@ -63,16 +190,11 @@ class _SettingsScreenState extends State { title: const Text('Notifications'), tiles: [ SettingsTile.switchTile( + leading: const Icon(Icons.notifications_outlined), title: const Text('Enable Notifications'), initialValue: _settings!.notificationsEnabled, - onToggle: (val) async { - final newSettings = AppSettings( - compression: _settings!.compression, - notificationsEnabled: val, - ); - await _repo.saveSettings(newSettings); - setState(() => _settings = newSettings); - }, + onToggle: (val) => + _save(_settings!.copyWith(notificationsEnabled: val)), ), ], ), diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index fda6f3f..36580e7 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -15,6 +15,10 @@ class NotificationService { static const _msgChannelId = 'seeonce_messages'; static const _msgChannelName = 'Received Messages'; + /// Invoked with the peer id when the user taps a notification. Wired by the + /// app shell to open that peer's conversation. + void Function(String peerId)? onNotificationTap; + Future initialize() async { const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); const iosInit = DarwinInitializationSettings( @@ -31,13 +35,27 @@ class NotificationService { await _plugin.initialize( initSettings, onDidReceiveNotificationResponse: (response) { - // Handle notification tap - // response.payload contains peerId - // The router should be designed to handle this (e.g., via global key or provider) + final peerId = response.payload; + if (peerId != null && peerId.isNotEmpty) { + onNotificationTap?.call(peerId); + } }, ); } + /// If the app was cold-launched by tapping a notification, returns that + /// notification's peer id so the app can open the conversation directly. + Future getLaunchPeerId() async { + try { + final details = await _plugin.getNotificationAppLaunchDetails(); + if (details?.didNotificationLaunchApp ?? false) { + final peerId = details!.notificationResponse?.payload; + if (peerId != null && peerId.isNotEmpty) return peerId; + } + } catch (_) {} + return null; + } + Future requestPermission() async { bool granted = false; diff --git a/lib/rtc/connection_supervisor.dart b/lib/rtc/connection_supervisor.dart index 654ea95..21b5e7f 100644 --- a/lib/rtc/connection_supervisor.dart +++ b/lib/rtc/connection_supervisor.dart @@ -58,6 +58,7 @@ class ConnectionSupervisor { StreamSubscription? _poolSub; final Map _backoff = {}; bool _running = false; + bool _sweeping = false; void start({required PeerRepository peerRepository}) { if (_running) return; @@ -92,23 +93,33 @@ class ConnectionSupervisor { /// has elapsed; resets backoff for peers that are healthy. Public for tests. @visibleForTesting Future superviseOnce(DateTime now) async { - final peers = await (_loadPeersOverride?.call() ?? - _repo?.getAllPeers() ?? - Future.value([])); - for (final p in peers) { - if (_isOnline(p.id)) { - _backoff.remove(p.id); // healthy — reset - continue; - } - final b = _backoff[p.id]; - if (b != null && now.isBefore(b.nextAttempt)) continue; // within backoff - final attempt = (b?.attempt ?? 0) + 1; - _backoff[p.id] = _Backoff(attempt, now.add(backoffFor(attempt))); - try { - await _reconnect(p); - } catch (e) { - if (kDebugMode) debugPrint('[Supervisor] reconnect ${p.id} failed: $e'); + // Pool change events can fire mid-sweep (a reconnect succeeding triggers + // one); without this guard sweeps overlap and double-fire attempts. + if (_sweeping) return; + _sweeping = true; + try { + final peers = await (_loadPeersOverride?.call() ?? + _repo?.getAllPeers() ?? + Future.value([])); + for (final p in peers) { + if (_isOnline(p.id)) { + _backoff.remove(p.id); // healthy — reset + continue; + } + final b = _backoff[p.id]; + if (b != null && now.isBefore(b.nextAttempt)) continue; // within backoff + final attempt = (b?.attempt ?? 0) + 1; + _backoff[p.id] = _Backoff(attempt, now.add(backoffFor(attempt))); + try { + await _reconnect(p); + } catch (e) { + if (kDebugMode) { + debugPrint('[Supervisor] reconnect ${p.id} failed: $e'); + } + } } + } finally { + _sweeping = false; } } diff --git a/lib/rtc/local_reconnect_service.dart b/lib/rtc/local_reconnect_service.dart index 7c21b6f..35635cf 100644 --- a/lib/rtc/local_reconnect_service.dart +++ b/lib/rtc/local_reconnect_service.dart @@ -22,6 +22,16 @@ void _log(String message) { if (kDebugMode) debugPrint(message); } +/// An in-flight answer to a peer's reconnect offer. The offer SDP is kept so a +/// re-broadcast of the *same* offer (we send each offer to several broadcast +/// addresses) is recognised and ignored, while a genuinely new offer from a +/// retry replaces the stale session. +class _AnswerSession { + final String offerSdp; + final RTCPeerConnection pc; + _AnswerSession(this.offerSdp, this.pc); +} + /// Automatically re-establishes WebRTC connections to known peers on the /// local network after an app restart, without requiring a new QR scan. /// @@ -42,6 +52,10 @@ class LocalReconnectService { /// Active outgoing offers, keyed by target peer's key hash. final _pendingByTarget = {}; + /// Active incoming answers, keyed by the sender peer's key hash, so duplicate + /// offer datagrams don't spawn (and leak) a second answer connection. + final _answeringByPeer = {}; + /// Peer ids with a reconnect attempt currently in flight. Drives the /// "Reconnecting…" indicator in the UI (peer list + open conversation). final _reconnecting = {}; @@ -54,6 +68,11 @@ class LocalReconnectService { /// backoff, so a stuck attempt doesn't spin forever. static const _attemptTimeout = Duration(seconds: 15); + /// How long an opened channel may take to complete the mutual auth handshake + /// before it is torn down. Without this a peer that opens a channel but never + /// answers the challenge would leave the connection dangling forever. + static const _authTimeout = Duration(seconds: 10); + /// Fires whenever the set of in-flight reconnect attempts changes. Stream get onReconnectActivity => _activityController.stream; @@ -94,22 +113,12 @@ class LocalReconnectService { return; } - try { - _socket = await RawDatagramSocket.bind( - InternetAddress.anyIPv4, - _port, - reuseAddress: true, - ); - _socket!.broadcastEnabled = true; - _log('[Reconnect] UDP socket bound on port $_port'); - } catch (e, st) { - _log('[Reconnect] Socket bind failed: $e\n$st'); - _started = false; + if (!await _ensureSocket()) { + // Bind failed (e.g. port momentarily in use). _ensureSocket is retried + // on every reconnect attempt, so this is not fatal — just degraded. return; } - _socket!.listen(_onDatagram); - try { final peers = await peerRepository.getAllPeers(); _log('[Reconnect] Known peers: ${peers.length}'); @@ -127,11 +136,34 @@ class LocalReconnectService { } } + /// Binds (or re-binds) the UDP socket. Returns true when a listening socket + /// is available. Called lazily so a failed bind at startup self-heals on the + /// supervisor's next reconnect sweep instead of disabling reconnect until + /// the next app launch. + Future _ensureSocket() async { + if (_socket != null) return true; + try { + final socket = await RawDatagramSocket.bind( + InternetAddress.anyIPv4, + _port, + reuseAddress: true, + ); + socket.broadcastEnabled = true; + socket.listen(_onDatagram); + _socket = socket; + _log('[Reconnect] UDP socket bound on port $_port'); + return true; + } catch (e, st) { + _log('[Reconnect] Socket bind failed: $e\n$st'); + return false; + } + } + /// Manually trigger reconnect for a single offline peer (e.g. from UI button). Future reconnectPeer(Peer peer) async { _log( '[Reconnect] Manual reconnect requested for ${peer.displayName}'); - if (_socket == null) { + if (!await _ensureSocket()) { _log('[Reconnect] Socket not ready — reconnect aborted'); return; } @@ -154,6 +186,14 @@ class LocalReconnectService { } } _pendingByTarget.clear(); + for (final session in _answeringByPeer.values) { + try { + await session.pc.close(); + } catch (e) { + _log('[Reconnect] stop: answer pc.close error: $e'); + } + } + _answeringByPeer.clear(); for (final t in _reconnectTimers.values) { t.cancel(); } @@ -167,17 +207,34 @@ class LocalReconnectService { // ── Mutual authentication ────────────────────────────────────────────────── - /// Proves both peers hold the private key matching their stored public key - /// before a reconnected channel is trusted as "online". + /// Wire/protocol version of the reconnect auth handshake. v2 adds DTLS + /// channel binding (see [_authenticateChannel]); it is incompatible with v1 + /// peers, which forces a re-pair when only one side has upgraded. + static const int _authProtocolVersion = 2; + + /// Proves both peers hold the private key matching their stored public key, + /// *and* that the authenticated peer is the one on the other end of this + /// exact DTLS connection — not a relay — before the channel is trusted. /// /// Reconnect offers/answers travel unauthenticated over LAN UDP broadcast, so - /// without this an attacker could spoof a known peer's presence. Each side - /// sends a fresh random nonce; the partner signs it (RSASSA/SHA-256) and we - /// verify the signature against the peer's stored public key. Only on success - /// do we call [onSuccess] (which registers the channel). Confidentiality is - /// already guaranteed by end-to-end encryption; this adds authenticity. - Future _authenticateChannel( - RTCDataChannel dc, Peer peer, void Function() onSuccess) async { + /// without this an attacker could spoof a known peer's presence or relay the + /// handshake between two victims. Each side sends a fresh random nonce; the + /// partner signs `nonce || channelBinding` (RSASSA/SHA-256) and we verify it + /// against the peer's stored public key. + /// + /// The channel binding is a SHA-256 over the two DTLS certificate + /// fingerprints (ours + the peer's, in canonical order). A direct connection + /// yields the same binding on both ends; a relay must terminate DTLS with its + /// own certificate on each leg, so the fingerprints — and the binding — no + /// longer match, and the signature fails to verify. Confidentiality is + /// already guaranteed by end-to-end encryption; this adds authenticity and + /// relay resistance. + /// + /// The whole exchange must finish within [_authTimeout]; otherwise the + /// channel and connection are closed so a silent partner cannot leave a + /// half-trusted link (and its resources) dangling. + Future _authenticateChannel(RTCDataChannel dc, RTCPeerConnection pc, + Peer peer, void Function() onSuccess) async { final RSAPrivateKey ownPriv; final RSAPublicKey peerPub; try { @@ -185,14 +242,39 @@ class LocalReconnectService { peerPub = KeyStore.instance.importPublicKeyPem(peer.publicKeyPem); } catch (e) { _log('[Reconnect] auth setup failed for ${peer.displayName}: $e'); - return; // leave the channel unregistered + unawaited(_closeQuietly(dc, pc)); + return; } + // Our view of the DTLS channel binding. Both ends must agree on this for a + // direct (non-relayed) connection. + final cb = await _channelBinding(pc); + final cbBytes = utf8.encode(cb); + final rnd = Random.secure(); final myNonce = Uint8List.fromList(List.generate(32, (_) => rnd.nextInt(256))); var done = false; + final timeout = Timer(_authTimeout, () { + if (done) return; + done = true; + _log('[Reconnect] ⏱ Auth timed out for ${peer.displayName} ' + '— closing channel'); + dc.onMessage = null; + unawaited(_closeQuietly(dc, pc)); + }); + + void fail(String why) { + if (done) return; + done = true; + timeout.cancel(); + _log('[Reconnect] ❌ Auth failed for ${peer.displayName} ($why) ' + '— refusing to register'); + dc.onMessage = null; + unawaited(_closeQuietly(dc, pc)); + } + dc.onMessage = (msg) { if (msg.isBinary) return; try { @@ -200,24 +282,39 @@ class LocalReconnectService { switch (m['type']) { case 'auth_challenge': final theirNonce = base64Decode(m['nonce'] as String); - final sig = RsaCipher.signWithPrivateKey(ownPriv, theirNonce); + // Sign their nonce bound to our view of the channel. + final toSign = Uint8List.fromList([...theirNonce, ...cbBytes]); + final sig = RsaCipher.signWithPrivateKey(ownPriv, toSign); dc.send(RTCDataChannelMessage(jsonEncode({ 'type': 'auth_response', 'sig': base64Encode(sig), + 'cb': cb, + 'v': _authProtocolVersion, }))); break; case 'auth_response': if (done) return; + // Reject anything that isn't the channel-bound v2 handshake (e.g. + // an unupgraded v1 peer, or a relay that stripped the binding). + if ((m['v'] as int?) != _authProtocolVersion) { + fail('unsupported handshake version'); + return; + } + // Relay check: both ends must observe the same DTLS fingerprints. + if ((m['cb'] as String?) != cb) { + fail('channel binding mismatch'); + return; + } final sig = base64Decode(m['sig'] as String); - if (RsaCipher.verifyWithPublicKey(peerPub, myNonce, sig)) { + final signed = Uint8List.fromList([...myNonce, ...cbBytes]); + if (RsaCipher.verifyWithPublicKey(peerPub, signed, sig)) { done = true; + timeout.cancel(); _log('[Reconnect] ✅ Authenticated ${peer.displayName}'); dc.onMessage = null; // hand the channel back to the app layer onSuccess(); } else { - _log('[Reconnect] ❌ Auth failed for ${peer.displayName} ' - '— refusing to register'); - dc.close(); + fail('signature mismatch'); } break; } @@ -229,9 +326,57 @@ class LocalReconnectService { dc.send(RTCDataChannelMessage(jsonEncode({ 'type': 'auth_challenge', 'nonce': base64Encode(myNonce), + 'v': _authProtocolVersion, }))); } + /// Extracts the DTLS fingerprint from an SDP's `a=fingerprint` line. + static String? _fingerprintFromSdp(String? sdp) { + if (sdp == null) return null; + final m = + RegExp(r'a=fingerprint:\S+\s+([0-9A-Fa-f:]+)').firstMatch(sdp); + return m?.group(1)?.toUpperCase(); + } + + /// Channel-binding tag for [pc]: SHA-256 over the local and remote DTLS + /// fingerprints in canonical (sorted) order, so both ends derive the same + /// value for a direct connection. Returns an empty string if the fingerprints + /// can't be read, degrading to no binding rather than breaking reconnect + /// (a relay can't force this path: a working DTLS connection always carries + /// fingerprints). + static Future _channelBinding(RTCPeerConnection pc) async { + try { + final local = await pc.getLocalDescription(); + final remote = await pc.getRemoteDescription(); + final lf = _fingerprintFromSdp(local?.sdp); + final rf = _fingerprintFromSdp(remote?.sdp); + if (lf == null || rf == null) return ''; + final ordered = [lf, rf]..sort(); + final digest = SHA256Digest(); + final bytes = Uint8List.fromList(utf8.encode(ordered.join('|'))); + digest.update(bytes, 0, bytes.length); + final out = Uint8List(digest.digestSize); + digest.doFinal(out, 0); + return base64Encode(out); + } catch (e) { + _log('[Reconnect] channel-binding computation failed: $e'); + return ''; + } + } + + Future _closeQuietly(RTCDataChannel? dc, RTCPeerConnection? pc) async { + if (dc != null) { + try { + await dc.close(); + } catch (_) {} + } + if (pc != null) { + try { + await pc.close(); + } catch (_) {} + } + } + // ── Key hash ─────────────────────────────────────────────────────────────── static String _keyHash(String pem) { @@ -265,7 +410,10 @@ class LocalReconnectService { } try { - final pc = await RtcManager.instance.createPeerConnection(); + // LAN-only (no STUN): both peers are on this subnet by construction, and + // host-only gathering completes in milliseconds instead of waiting up to + // the bundler's hard deadline for a STUN round-trip. + final pc = await RtcManager.instance.createPeerConnection(lanOnly: true); _log('[Reconnect] PeerConnection created for ${targetPeer.displayName}'); final dc = await RtcManager.instance.createDataChannel(pc); @@ -276,7 +424,7 @@ class LocalReconnectService { if (state == RTCDataChannelState.RTCDataChannelOpen) { _pendingByTarget.remove(toPeerKeyHash); // Register only after the peer proves possession of its private key. - _authenticateChannel(dc, targetPeer, () { + _authenticateChannel(dc, pc, targetPeer, () { PeerConnectionPool.instance.register(targetPeer.id, pc, dc); _clearReconnecting(targetPeer.id); _log( @@ -290,8 +438,12 @@ class LocalReconnectService { '[Reconnect] ConnectionState → $s (initiator → ${targetPeer.displayName})'); if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed || s == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { + // This handler only lives until registration (register() replaces + // it), so a failure here is always pre-trust: drop the attempt and + // free the connection instead of leaking it. _pendingByTarget.remove(toPeerKeyHash); _clearReconnecting(targetPeer.id); + unawaited(_closeQuietly(dc, pc)); _log( '[Reconnect] Connection failed/disconnected for ${targetPeer.displayName}'); } @@ -314,7 +466,7 @@ class LocalReconnectService { _pendingByTarget[toPeerKeyHash] = pc; - _broadcast({ + await _broadcast({ 'type': 'reconnect_offer', 'from_key_hash': _ownKeyHash, 'to_key_hash': toPeerKeyHash, @@ -385,6 +537,21 @@ class LocalReconnectService { return; } + // Each offer is sent to several broadcast addresses, so the same datagram + // routinely arrives more than once. Answering twice would replace (and + // break) the connection the initiator is already completing. + final existing = _answeringByPeer[fromKeyHash]; + if (existing != null) { + if (existing.offerSdp == sdp) { + _log('[Reconnect] Duplicate offer datagram — already answering'); + return; + } + // A genuinely new offer (initiator retried): drop the stale session. + _log('[Reconnect] New offer supersedes in-flight answer — replacing'); + _answeringByPeer.remove(fromKeyHash); + unawaited(_closeQuietly(null, existing.pc)); + } + final senderPeer = await _findPeerByKeyHash(fromKeyHash); if (senderPeer == null) { _log( @@ -412,7 +579,8 @@ class LocalReconnectService { } try { - final pc = await RtcManager.instance.createPeerConnection(); + final pc = await RtcManager.instance.createPeerConnection(lanOnly: true); + _answeringByPeer[fromKeyHash] = _AnswerSession(sdp, pc); _log( '[Reconnect] PeerConnection created (answerer for ${senderPeer.displayName})'); @@ -424,7 +592,8 @@ class LocalReconnectService { '[Reconnect] DC state → $state (answerer ← ${senderPeer.displayName})'); if (state == RTCDataChannelState.RTCDataChannelOpen) { // Register only after mutual private-key proof. - _authenticateChannel(dc, senderPeer, () { + _authenticateChannel(dc, pc, senderPeer, () { + _answeringByPeer.remove(fromKeyHash); PeerConnectionPool.instance.register(senderPeer.id, pc, dc); _clearReconnecting(senderPeer.id); _log( @@ -439,6 +608,13 @@ class LocalReconnectService { '[Reconnect] ConnectionState → $s (answerer ← ${senderPeer.displayName})'); if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed || s == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { + // Pre-registration failure (register() replaces this handler): + // free the session instead of leaking it. + final session = _answeringByPeer[fromKeyHash]; + if (session != null && identical(session.pc, pc)) { + _answeringByPeer.remove(fromKeyHash); + unawaited(_closeQuietly(null, pc)); + } _clearReconnecting(senderPeer.id); } }; @@ -491,6 +667,8 @@ class LocalReconnectService { } catch (e, st) { _log( '[Reconnect] _handleIncomingOffer failed for ${senderPeer.displayName}: $e\n$st'); + final session = _answeringByPeer.remove(fromKeyHash); + if (session != null) unawaited(_closeQuietly(null, session.pc)); _clearReconnecting(senderPeer.id); } } @@ -550,13 +728,8 @@ class LocalReconnectService { Future _findPeerByKeyHash(String keyHash) async { try { final peers = await _peerRepository?.getAllPeers() ?? []; - _log( - '[Reconnect] _findPeerByKeyHash: searching ${peers.length} peers for hash=$keyHash'); for (final p in peers) { - final h = _keyHash(p.publicKeyPem); - _log( - '[Reconnect] peer=${p.displayName} id=${p.id} hash=$h'); - if (h == keyHash) return p; + if (_keyHash(p.publicKeyPem) == keyHash) return p; } _log('[Reconnect] _findPeerByKeyHash: no match for $keyHash'); return null; @@ -566,8 +739,31 @@ class LocalReconnectService { } } - void _broadcast(Map payload) { + /// Sends [payload] to the global broadcast address and to each interface's + /// directed broadcast (assuming a /24, which covers typical home/office + /// LANs). Many routers and APs silently drop 255.255.255.255, so relying on + /// it alone made reconnect fail on those networks. + Future _broadcast(Map payload) async { _send(payload, InternetAddress('255.255.255.255'), _port); + try { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.IPv4, + ); + final sent = {}; + for (final iface in interfaces) { + for (final addr in iface.addresses) { + final parts = addr.address.split('.'); + if (parts.length != 4) continue; + final directed = '${parts[0]}.${parts[1]}.${parts[2]}.255'; + if (sent.add(directed)) { + _send(payload, InternetAddress(directed), _port); + } + } + } + } catch (e) { + _log('[Reconnect] directed broadcast enumeration failed: $e'); + } } void _unicast(Map payload, InternetAddress address) { diff --git a/lib/rtc/rtc_channel_handler.dart b/lib/rtc/rtc_channel_handler.dart index 9d1a39b..aaf3ebb 100644 --- a/lib/rtc/rtc_channel_handler.dart +++ b/lib/rtc/rtc_channel_handler.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../../core/constants.dart'; +import '../../core/errors.dart'; class _ChunkBuffer { final int totalChunks; @@ -61,6 +62,15 @@ class RtcChannelHandler { _sweepTimer = Timer.periodic(_bufferTtl, (_) => sweepStaleBuffers()); } + /// Backpressure: pause sending while more than this is queued in the data + /// channel's native buffer. SCTP buffers are finite; letting the queue grow + /// unboundedly risks both memory spikes and channel closure on overflow. + static const int _sendHighWaterMark = 1 << 20; // 1 MB + + /// Upper bound on a single backpressure wait. If the buffer hasn't drained + /// in this long the link is effectively dead — fail instead of hanging. + static const Duration _drainTimeout = Duration(seconds: 30); + Future sendEncryptedPayload({ required RTCDataChannel dc, required Uint8List encryptedPayload, @@ -69,6 +79,9 @@ class RtcChannelHandler { }) async { final chunkSize = AppConstants.dataChannelChunkSize; final totalChunks = (encryptedPayload.length / chunkSize).ceil(); + final msgIdBytes = utf8.encode(messageId); + + _ensureOpen(dc); // 1. Send Header final header = { @@ -85,10 +98,7 @@ class RtcChannelHandler { ? encryptedPayload.length : start + chunkSize; - final chunkData = encryptedPayload.sublist(start, end); - // Frame layout: [messageIdLength(1)][messageId(bytes)][chunkIndex(4)][chunkData] - final msgIdBytes = utf8.encode(messageId); final builder = BytesBuilder(); builder.addByte(msgIdBytes.length); builder.add(msgIdBytes); @@ -97,18 +107,35 @@ class RtcChannelHandler { indexData.setInt32(0, i, Endian.big); builder.add(indexData.buffer.asUint8List()); - builder.add(chunkData); - - // Basic flow control: wait if buffer gets too large - if (dc.bufferedAmount != null && dc.bufferedAmount! > 1024 * 1024 * 8) { - // > 8MB - await Future.delayed(const Duration(milliseconds: 100)); - } + builder.add(Uint8List.sublistView(encryptedPayload, start, end)); + await _waitForDrain(dc); + _ensureOpen(dc); await dc.send(RTCDataChannelMessage.fromBinary(builder.toBytes())); } } + void _ensureOpen(RTCDataChannel dc) { + if (dc.state != RTCDataChannelState.RTCDataChannelOpen) { + throw const ConnectionError('Connection lost while sending.'); + } + } + + /// Waits until the channel's buffered amount falls below the high-water + /// mark, so large payloads (videos) stream at the rate the link can take. + Future _waitForDrain(RTCDataChannel dc) async { + var waited = Duration.zero; + const step = Duration(milliseconds: 20); + while ((dc.bufferedAmount ?? 0) > _sendHighWaterMark) { + if (dc.state != RTCDataChannelState.RTCDataChannelOpen || + waited >= _drainTimeout) { + throw const ConnectionError('Connection stalled while sending.'); + } + await Future.delayed(step); + waited += step; + } + } + void onMessage(RTCDataChannelMessage message) { if (!message.isBinary) { _handleTextMessage(message.text); diff --git a/lib/rtc/rtc_manager.dart b/lib/rtc/rtc_manager.dart index 0b77201..4371d51 100644 --- a/lib/rtc/rtc_manager.dart +++ b/lib/rtc/rtc_manager.dart @@ -7,13 +7,24 @@ class RtcManager { final Map _config = { 'iceServers': [ - {'urls': AppConstants.stunServer} + {'urls': AppConstants.stunServers} ], 'sdpSemantics': 'unified-plan', }; - Future createPeerConnection() async { - return await webrtc.createPeerConnection(_config, {}); + /// No STUN: gathering emits host candidates only and completes almost + /// immediately instead of waiting out a STUN round-trip. Used for LAN + /// reconnects, where the UDP-broadcast signalling already guarantees both + /// peers share a network and srflx candidates add nothing but latency. + final Map _lanOnlyConfig = { + 'iceServers': [], + 'sdpSemantics': 'unified-plan', + }; + + Future createPeerConnection( + {bool lanOnly = false}) async { + return await webrtc.createPeerConnection( + lanOnly ? _lanOnlyConfig : _config, {}); } Future createDataChannel(webrtc.RTCPeerConnection pc) async { diff --git a/lib/widgets/peer_tile.dart b/lib/widgets/peer_tile.dart index f98f562..acd65c6 100644 --- a/lib/widgets/peer_tile.dart +++ b/lib/widgets/peer_tile.dart @@ -1,8 +1,13 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' show RTCDataChannelMessage; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import '../core/constants.dart'; +import '../core/theme.dart'; import '../data/models/peer.dart'; import '../features/conversation/providers/conversation_provider.dart'; import '../rtc/local_reconnect_service.dart'; @@ -64,8 +69,8 @@ class _PeerTileState extends ConsumerState { } Color get _statusColor => _isOnline - ? Colors.green - : (_isReconnecting ? Colors.orange : Colors.grey); + ? AppColors.online + : (_isReconnecting ? AppColors.reconnecting : AppColors.offline); @override Widget build(BuildContext context) { @@ -92,7 +97,7 @@ class _PeerTileState extends ConsumerState { width: 24, height: 24, child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.orange), + strokeWidth: 2, color: AppColors.reconnecting), ), ) else @@ -123,8 +128,22 @@ class _PeerTileState extends ConsumerState { ), ); }, - onDismissed: (direction) { - ref.read(conversationProvider.notifier).onUnpairReceived(widget.peer.id); + onDismissed: (direction) async { + // Tell the peer we unpaired (parity with the in-conversation unpair), + // so they don't keep a dead pairing forever. Must happen before the + // local teardown closes the channel. + final dc = PeerConnectionPool.instance.getChannel(widget.peer.id); + if (dc != null) { + try { + await dc.send(RTCDataChannelMessage(jsonEncode({ + 'type': AppConstants.msgTypeUnpair, + 'peerId': widget.peer.id, + }))); + } catch (_) {} + } + await ref + .read(conversationProvider.notifier) + .onUnpairReceived(widget.peer.id); }, background: Container( color: Colors.red, @@ -166,9 +185,9 @@ class _PeerTileState extends ConsumerState { ? 'Connected' : (_isReconnecting ? 'Reconnecting…' - : 'Offline · Paired ${widget.peer.pairedAt.toLocal().toString().split('.')[0]}'), + : 'Offline · Paired ${DateFormat.yMMMd().format(widget.peer.pairedAt.toLocal())}'), style: TextStyle( - color: _isReconnecting ? Colors.orange : null, + color: _isReconnecting ? AppColors.reconnecting : null, ), ), trailing: trailing, diff --git a/lib/widgets/secure_video_widget.dart b/lib/widgets/secure_video_widget.dart index 8e57466..7743891 100644 --- a/lib/widgets/secure_video_widget.dart +++ b/lib/widgets/secure_video_widget.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -24,6 +26,12 @@ class _SecureVideoWidgetState extends State { VideoPlayerController? _controller; String? _error; + /// Unguessable per-view URL path. Loopback is reachable by every app on the + /// device, so without this any local process could port-scan 127.0.0.1 and + /// download the decrypted video while the viewer is open. + late final String _token = base64UrlEncode( + List.generate(16, (_) => Random.secure().nextInt(256))); + @override void initState() { super.initState(); @@ -33,12 +41,11 @@ class _SecureVideoWidgetState extends State { Future _setup() async { try { // Bind to loopback on a random free port; only this device can reach it. - final server = - await HttpServer.bind(InternetAddress.loopbackIPv4, 0, shared: true); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); _server = server; server.listen(_handleRequest); - final url = 'http://${server.address.address}:${server.port}/v.mp4'; + final url = 'http://${server.address.address}:${server.port}/$_token.mp4'; final controller = VideoPlayerController.networkUrl(Uri.parse(url)); _controller = controller; await controller.initialize(); @@ -58,6 +65,11 @@ class _SecureVideoWidgetState extends State { final bytes = widget.videoBytes; final total = bytes.length; final res = request.response; + if (request.uri.path != '/$_token.mp4') { + res.statusCode = HttpStatus.notFound; + await res.close(); + return; + } try { res.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); res.headers.contentType = ContentType('video', 'mp4'); diff --git a/pubspec.lock b/pubspec.lock index 8f4e635..a133ed1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -496,14 +496,6 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 - url: "https://pub.dev" - source: hosted - version: "6.3.3" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 050f8a6..f5be484 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,9 +49,6 @@ dependencies: qr_flutter: ^4.1.0 mobile_scanner: ^5.2.3 - # Typography - google_fonts: ^6.2.1 - # Utilities uuid: ^4.5.1 intl: ^0.20.2 @@ -72,10 +69,25 @@ flutter: uses-material-design: true fonts: - # Bundled color-emoji fallback (Noto COLRv1) so emoji render identically on - # every device. Older Androids ship an emoji font that lacks newer glyphs, - # which showed received emoji as empty boxes. Wired in as fontFamilyFallback - # in lib/app.dart. + # Inter, bundled locally. Previously the app pulled Inter at runtime via + # google_fonts; when the device was offline (or before the download + # finished on first launch) the primary font was missing and ALL text fell + # through to NotoColorEmoji — rendering digits as gradient emoji glyphs and + # letters with broken spacing. Bundling removes the network dependency. + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.ttf + weight: 400 + - asset: assets/fonts/Inter-Medium.ttf + weight: 500 + - asset: assets/fonts/Inter-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Inter-Bold.ttf + weight: 700 + # Color-emoji fallback (Noto COLRv1) so emoji render identically on every + # device. Older Androids ship an emoji font that lacks newer glyphs, which + # showed received emoji as empty boxes. Only consulted for glyphs Inter + # lacks (i.e. emoji), so it no longer affects digits/letters. - family: NotoColorEmoji fonts: - asset: assets/fonts/NotoColorEmoji.ttf diff --git a/test/messaging/emoji_roundtrip_test.dart b/test/messaging/emoji_roundtrip_test.dart index 3386f1a..a6e8c10 100644 --- a/test/messaging/emoji_roundtrip_test.dart +++ b/test/messaging/emoji_roundtrip_test.dart @@ -29,6 +29,8 @@ class _FakeDc implements RTCDataChannel { @override int? get bufferedAmount => 0; @override + RTCDataChannelState? get state => RTCDataChannelState.RTCDataChannelOpen; + @override noSuchMethod(Invocation invocation) => null; } diff --git a/test/rtc/rtc_channel_handler_test.dart b/test/rtc/rtc_channel_handler_test.dart index 0c03396..e489869 100644 --- a/test/rtc/rtc_channel_handler_test.dart +++ b/test/rtc/rtc_channel_handler_test.dart @@ -4,6 +4,22 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:seeonce_app/rtc/rtc_channel_handler.dart'; import 'package:seeonce_app/core/constants.dart'; +import 'package:seeonce_app/core/errors.dart'; + +/// Minimal fake channel for exercising the send path without WebRTC. +class _FakeDc implements RTCDataChannel { + _FakeDc({this.channelState = RTCDataChannelState.RTCDataChannelOpen}); + final RTCDataChannelState channelState; + final List sent = []; + @override + Future send(RTCDataChannelMessage message) async => sent.add(message); + @override + int? get bufferedAmount => 0; + @override + RTCDataChannelState? get state => channelState; + @override + noSuchMethod(Invocation invocation) => null; +} /// Builds the binary chunk message that [RtcChannelHandler.sendEncryptedPayload] /// would produce, without needing a real [RTCDataChannel]. @@ -236,6 +252,24 @@ void main() { h.dispose(); }); + test('sending on a non-open channel fails fast with ConnectionError', + () async { + final h = RtcChannelHandler(); + final dc = + _FakeDc(channelState: RTCDataChannelState.RTCDataChannelClosed); + await expectLater( + h.sendEncryptedPayload( + dc: dc, + encryptedPayload: Uint8List.fromList([1, 2, 3]), + messageId: 'm', + senderId: 's', + ), + throwsA(isA()), + ); + expect(dc.sent, isEmpty, reason: 'nothing should be queued on a dead dc'); + h.dispose(); + }); + test('stale incomplete buffers are swept', () { final h = RtcChannelHandler(bufferTtl: Duration.zero); h.onMessage(buildHeaderMessage(messageId: 'stale', totalChunks: 3));