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));