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(