From e8d070abaf0e466fb10e71dd83a9a7dc24fd4805 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 15:31:24 +0200 Subject: [PATCH] fix(samples): no encryption where the platform cannot do it Only Android, iOS and macOS ship the encryption manager, so the lobby's switch is gone everywhere else and the card says why. An encrypted call opened there says it cannot be joined and the join button stays disabled. Co-Authored-By: Claude Opus 5 --- dogfooding/lib/screens/lobby_screen.dart | 43 +++++++++++++-- dogfooding/lib/widgets/lobby_encryption.dart | 55 +++++++++++++++++--- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/dogfooding/lib/screens/lobby_screen.dart b/dogfooding/lib/screens/lobby_screen.dart index 781845ae2..a672b80af 100644 --- a/dogfooding/lib/screens/lobby_screen.dart +++ b/dogfooding/lib/screens/lobby_screen.dart @@ -64,6 +64,12 @@ class _LobbyScreenState extends State { final _userAuthController = locator.get(); late final StreamVideoEffectsManager _videoEffectsManager; + /// Whether this platform can encrypt and decrypt at all. + /// + /// Only Android, iOS and macOS ship the encryption manager, so everywhere + /// else the switch is gone and an encrypted call cannot be joined. + final bool _encryptionSupported = EncryptionManager.isSupported; + /// Whether to create the call encrypted. Only meaningful until the call /// exists, after which the call itself is the answer. bool _encryptionEnabled = false; @@ -82,6 +88,12 @@ class _LobbyScreenState extends State { /// Whether the call exists: either it already did, or this screen made it. bool get _callExists => widget.callExists || _created; + /// Whether the call being joined is encrypted: its own setting once it + /// exists, the switch until then. + bool get _willBeEncrypted => _callExists + ? isCallEncrypted(widget.call.state.value.settings) + : _encryptionEnabled; + /// Set once the call has been handed to the call screen, which owns the /// encryption manager from then on. bool _joining = false; @@ -93,9 +105,11 @@ class _LobbyScreenState extends State { // An invite that carries a key is an invite to an encrypted call, so the // user has nothing to fill in. For a call that does not exist yet, it also - // decides that the call is created encrypted. + // decides that the call is created encrypted — which is why a platform + // that cannot encrypt ignores the key rather than creating a call it + // would then be locked out of. final invitedKey = widget.initialEncryptionKey; - if (invitedKey != null && invitedKey.isNotEmpty) { + if (_encryptionSupported && invitedKey != null && invitedKey.isNotEmpty) { _encryptionEnabled = true; _setEncryptionKey(invitedKey); } @@ -116,6 +130,13 @@ class _LobbyScreenState extends State { Future _joinCallPressed(CallConnectOptions options) async { if (_creatingCall) return false; + // The button is disabled in this state, so this only catches a join that + // came from somewhere else — a deep link, or a host driving the view. + if (!_encryptionSupported && _willBeEncrypted) { + _showError('This call is encrypted, which this platform cannot do.'); + return false; + } + // Creation is deferred to here so the encryption switch stays live for as // long as it means anything: the mode is fixed at creation, and this is // the last moment before it is. @@ -126,6 +147,14 @@ class _LobbyScreenState extends State { // The manager has to be attached before any peer connection exists, and // the join happens on the next screen — so this is the last moment. final isEncrypted = isCallEncrypted(widget.call.state.value.settings); + + // `getOrCreate` may have found a call somebody else created encrypted, + // whatever this screen asked for. + if (isEncrypted && !_encryptionSupported) { + _showError('This call is encrypted, which this platform cannot do.'); + return false; + } + if (isEncrypted && _encryptionKey.isNotEmpty) { if (!await _attachE2EE() || !mounted) return false; } @@ -144,7 +173,7 @@ class _LobbyScreenState extends State { /// Derives the shared key and attaches a manager to the call. Future _attachE2EE() async { - if (!EncryptionManager.isSupported) { + if (!_encryptionSupported) { _showError('End-to-end encryption is not available on this platform.'); return false; } @@ -171,7 +200,7 @@ class _LobbyScreenState extends State { try { final result = await widget.call.getOrCreate( video: true, - encryption: _encryptionEnabled + encryption: _encryptionEnabled && _encryptionSupported ? const StreamEncryptionSettings(mode: StreamEncryptionMode.autoOn) : null, ); @@ -278,16 +307,20 @@ class _LobbyScreenState extends State { ? isEncrypted : _encryptionEnabled; final needsKey = willBeEncrypted && _encryptionKey.isEmpty; + // An encrypted call is unreachable from a platform without the + // encryption manager: every frame would arrive undecryptable. + final blocked = willBeEncrypted && !_encryptionSupported; return StreamLobbyView( call: widget.call, actions: actions, title: Text('Set up your call', style: textTheme.headingLg), joinButtonLabel: const Text('Start a test call'), - joinEnabled: !needsKey && !_creatingCall, + joinEnabled: !needsKey && !blocked && !_creatingCall, footer: LobbyEncryption( call: widget.call, callExists: _callExists, + supported: _encryptionSupported, encryptionEnabled: _encryptionEnabled, encryptionKey: _encryptionKey, busy: _creatingCall, diff --git a/dogfooding/lib/widgets/lobby_encryption.dart b/dogfooding/lib/widgets/lobby_encryption.dart index a83c0ad3b..c89bf3d3c 100644 --- a/dogfooding/lib/widgets/lobby_encryption.dart +++ b/dogfooding/lib/widgets/lobby_encryption.dart @@ -16,11 +16,15 @@ import '../utils/call_encryption.dart'; /// - created and encrypted → a read-only banner, plus the key field, which is /// all that is left to collect; /// - created and plain → the switch, greyed out. +/// +/// On a platform that cannot encrypt at all — see [supported] — none of that +/// applies and the card only reports where it stands. class LobbyEncryption extends StatelessWidget { const LobbyEncryption({ super.key, required this.call, required this.callExists, + required this.supported, required this.encryptionEnabled, required this.encryptionKey, required this.busy, @@ -35,6 +39,12 @@ class LobbyEncryption extends StatelessWidget { /// Whether [call] has already been created on the backend. final bool callExists; + /// Whether this platform can encrypt and decrypt at all. + /// + /// False on web, Windows and Linux. There is then nothing to switch and no + /// key worth collecting, so the card drops both. + final bool supported; + /// The mode the switch is asking for, meaningful only before creation. final bool encryptionEnabled; @@ -72,7 +82,12 @@ class LobbyEncryption extends StatelessWidget { ? isCallEncrypted(state.settings) : encryptionEnabled; - final needsKey = callExists && isOn && encryptionKey.isEmpty; + // An encrypted call this platform cannot decrypt: the key field would + // collect something nothing here can use, and the join is refused + // anyway, so all that is left is to say so. + final blocked = isOn && !supported; + final needsKey = + supported && callExists && isOn && encryptionKey.isEmpty; return SizedBox( // The same width as the join button below it, so the two line up. @@ -82,9 +97,11 @@ class LobbyEncryption extends StatelessWidget { borderRadius: BorderRadius.all(radius.lg), color: colorScheme.backgroundSurfaceCard, border: Border.all( - color: isOn - ? colorScheme.accentPrimary - : colorScheme.borderSubtle, + color: switch ((isOn, supported)) { + (true, false) => colorScheme.borderWarning, + (true, true) => colorScheme.accentPrimary, + (false, _) => colorScheme.borderSubtle, + }, ), ), child: Padding( @@ -93,7 +110,21 @@ class LobbyEncryption extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.md, children: [ - if (callExists && isOn) + if (!supported) + _Header( + title: 'End-to-end encryption', + subtitle: blocked + ? 'This call is encrypted and cannot be joined on ' + 'this platform' + : 'Not supported on this platform', + isOn: isOn, + warning: blocked, + // Nothing to switch: the platform decides, not the user. + trailing: blocked + ? null + : StreamSwitch(value: false, onChanged: null), + ) + else if (callExists && isOn) const _Header( title: 'End-to-end encryption', isOn: true, @@ -118,7 +149,7 @@ class LobbyEncryption extends StatelessWidget { duration: const Duration(milliseconds: 180), curve: Curves.easeOut, alignment: Alignment.topCenter, - child: !isOn + child: !isOn || !supported ? const SizedBox(width: double.infinity) : Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -199,12 +230,18 @@ class _Header extends StatelessWidget { required this.title, this.subtitle, required this.isOn, + this.warning = false, this.trailing, }); final String title; final String? subtitle; final bool isOn; + + /// Whether the state the header reports is one the user has to act on + /// elsewhere — an encrypted call this platform cannot join. + final bool warning; + final Widget? trailing; @override @@ -219,7 +256,11 @@ class _Header extends StatelessWidget { children: [ Icon( isOn ? icons.lock : icons.unlock, - color: isOn ? colorScheme.accentPrimary : colorScheme.textSecondary, + color: switch ((isOn, warning)) { + (_, true) => colorScheme.accentWarning, + (true, false) => colorScheme.accentPrimary, + (false, false) => colorScheme.textSecondary, + }, ), Expanded( child: Column(