Perché WebRTC in Flutter

Quando un'app deve offrire videochiamate, audio call o condivisione dello schermo in tempo reale, il protocollo di riferimento è WebRTC: uno standard supportato da tutti i browser e disponibile nativamente su Android e iOS. In Flutter il binding ufficiale è il pacchetto flutter_webrtc, mantenuto dalla community di CloudWebRTC, che espone un'API molto vicina a quella JavaScript (getUserMedia, RTCPeerConnection, RTCDataChannel).

A differenza di soluzioni "chiavi in mano" come Agora, Twilio Video o LiveKit, con flutter_webrtc si lavora direttamente sul livello più basso: questo significa controllo totale, zero vendor lock-in e costi legati solo alla propria infrastruttura, ma anche la responsabilità di implementare il signaling e di fornire server STUN/TURN.

In questa guida costruiamo una videochiamata 1-a-1 (peer-to-peer) completa, con signaling via WebSocket.

I tre pezzi del puzzle

Prima di scrivere codice è fondamentale avere chiaro il modello mentale:

  1. Media: i flussi audio/video locali, ottenuti dalla fotocamera e dal microfono con getUserMedia.
  2. Signaling: il canale fuori banda (non definito da WebRTC) con cui i due peer si scambiano le descrizioni di sessione SDP (offer/answer) e i candidati ICE. Può essere un WebSocket, Firestore, MQTT o qualsiasi altra cosa.
  3. Connettività (ICE): STUN per scoprire l'indirizzo pubblico dietro NAT, TURN per fare da relay quando la connessione diretta è impossibile.

Regola pratica: senza un server TURN, dal 10% al 20% delle chiamate reali fallisce (reti aziendali, NAT simmetrici, alcune reti mobili). Lo STUN pubblico di Google va bene per i test, non per la produzione.

Installazione e configurazione nativa

dependencies:
  flutter_webrtc: ^0.12.5
  web_socket_channel: ^3.0.1
  permission_handler: ^11.3.1

Android

In android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-feature android:name="android.hardware.camera" />

Il minSdkVersion deve essere almeno 23. Se usi R8/ProGuard, il pacchetto include già le regole necessarie.

iOS

In ios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>L'app usa la fotocamera per le videochiamate</string>
<key>NSMicrophoneUsageDescription</key>
<string>L'app usa il microfono per le chiamate</string>

E nel Podfile imposta platform :ios, '13.0' o superiore. Per ricevere chiamate ad app chiusa servirà poi l'integrazione con CallKit + PushKit (vedi ultima sezione).

Inizializzare i renderer e il media locale

I flussi video si mostrano con RTCVideoView, che richiede un RTCVideoRenderer inizializzato (e, importantissimo, smaltito nel dispose).

import 'package:flutter_webrtc/flutter_webrtc.dart';

class CallController {
  final localRenderer = RTCVideoRenderer();
  final remoteRenderer = RTCVideoRenderer();

  MediaStream? _localStream;
  RTCPeerConnection? _pc;

  Future<void> initRenderers() async {
    await localRenderer.initialize();
    await remoteRenderer.initialize();
  }

  Future<void> openUserMedia() async {
    final constraints = <String, dynamic>{
      'audio': true,
      'video': {
        'facingMode': 'user',
        'width': {'ideal': 1280},
        'height': {'ideal': 720},
        'frameRate': {'ideal': 30},
      },
    };

    _localStream = await navigator.mediaDevices.getUserMedia(constraints);
    localRenderer.srcObject = _localStream;
  }

  Future<void> dispose() async {
    await _localStream?.dispose();
    await _pc?.close();
    await localRenderer.dispose();
    await remoteRenderer.dispose();
  }
}

Nota: le constraints sono suggerimenti. WebRTC adatta dinamicamente risoluzione e bitrate alle condizioni di rete, quindi non serve (e non conviene) forzare valori rigidi.

Creare la RTCPeerConnection

Future<void> createPeerConnection_() async {
  final config = <String, dynamic>{
    'iceServers': [
      {'urls': 'stun:stun.l.google.com:19302'},
      {
        'urls': 'turn:turn.miodominio.it:3478',
        'username': 'utente',
        'credential': 'segreto-temporaneo',
      },
    ],
    // 'unified-plan' è lo standard moderno (default nelle versioni recenti)
    'sdpSemantics': 'unified-plan',
  };

  _pc = await createPeerConnection(config);

  // Pubblica le tracce locali verso il peer remoto
  for (final track in _localStream!.getTracks()) {
    await _pc!.addTrack(track, _localStream!);
  }

  // Riceve le tracce remote
  _pc!.onTrack = (RTCTrackEvent event) {
    if (event.track.kind == 'video' && event.streams.isNotEmpty) {
      remoteRenderer.srcObject = event.streams.first;
    }
  };

  // Candidati ICE da inviare all'altro peer
  _pc!.onIceCandidate = (RTCIceCandidate candidate) {
    _signaling.send({
      'type': 'candidate',
      'candidate': candidate.toMap(),
    });
  };

  _pc!.onConnectionState = (RTCPeerConnectionState state) {
    debugPrint('Connection state: $state');
    if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
      _pc!.restartIce(); // tentativo di recupero
    }
  };
}

Credenziali TURN: mai hardcoded

Le credenziali TURN vanno generate dal backend con il meccanismo delle ephemeral credentials (REST API di coturn): username = timestamp:userId, password = HMAC-SHA1 del username con un segreto condiviso. Validità tipica: 1-2 ore. Inserire user e password statici nel binario dell'app equivale a regalare banda a chiunque decompili l'APK.

Il signaling: chi chiama e chi risponde

Il flusso canonico di una chiamata 1-a-1 è questo:

  1. Caller: createOffer() → setLocalDescription() → invia l'SDP.
  2. Callee: riceve l'offer → setRemoteDescription() → createAnswer() → setLocalDescription() → rinvia l'SDP.
  3. Entrambi si scambiano i candidati ICE man mano che vengono generati (trickle ICE).

Un client di signaling minimale su WebSocket:

import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';

class SignalingClient {
  SignalingClient(this._uri);
  final Uri _uri;
  WebSocketChannel? _channel;

  final _controller = StreamController<Map<String, dynamic>>.broadcast();
  Stream<Map<String, dynamic>> get messages => _controller.stream;

  Future<void> connect(String roomId, String token) async {
    _channel = WebSocketChannel.connect(
      _uri.replace(queryParameters: {'room': roomId, 'token': token}),
    );
    _channel!.stream.listen(
      (raw) => _controller.add(jsonDecode(raw as String) as Map<String, dynamic>),
      onDone: _scheduleReconnect,
      onError: (_) => _scheduleReconnect(),
    );
  }

  void send(Map<String, dynamic> message) =>
      _channel?.sink.add(jsonEncode(message));

  void _scheduleReconnect() {
    // backoff esponenziale con jitter: il signaling deve sopravvivere
    // a cambi di rete Wi-Fi/4G senza far cadere la chiamata già stabilita
  }
}

Punto spesso sottovalutato: se il signaling cade dopo che la connessione è stabilita, la chiamata continua a funzionare. WebRTC scambia media direttamente tra i peer. Il signaling serve di nuovo solo in caso di rinegoziazione o ICE restart.

Lato caller

Future<void> makeCall() async {
  await createPeerConnection_();

  final offer = await _pc!.createOffer({
    'offerToReceiveAudio': true,
    'offerToReceiveVideo': true,
  });
  await _pc!.setLocalDescription(offer);

  _signaling.send({
    'type': 'offer',
    'sdp': offer.sdp,
    'sdpType': offer.type,
  });
}

Lato callee

Future<void> handleOffer(Map<String, dynamic> msg) async {
  await createPeerConnection_();

  await _pc!.setRemoteDescription(
    RTCSessionDescription(msg['sdp'] as String, msg['sdpType'] as String),
  );

  final answer = await _pc!.createAnswer();
  await _pc!.setLocalDescription(answer);

  _signaling.send({
    'type': 'answer',
    'sdp': answer.sdp,
    'sdpType': answer.type,
  });

  await _drainPendingCandidates();
}

Candidati ICE e il problema dell'ordine

I candidati remoti possono arrivare prima che setRemoteDescription sia stato chiamato: in quel caso addCandidate fallisce. La soluzione è bufferizzarli:

final List<RTCIceCandidate> _pendingCandidates = [];
bool _remoteDescriptionSet = false;

Future<void> handleCandidate(Map<String, dynamic> msg) async {
  final map = msg['candidate'] as Map<String, dynamic>;
  final candidate = RTCIceCandidate(
    map['candidate'] as String?,
    map['sdpMid'] as String?,
    map['sdpMLineIndex'] as int?,
  );

  if (_remoteDescriptionSet) {
    await _pc!.addCandidate(candidate);
  } else {
    _pendingCandidates.add(candidate);
  }
}

Future<void> _drainPendingCandidates() async {
  _remoteDescriptionSet = true;
  for (final c in _pendingCandidates) {
    await _pc!.addCandidate(c);
  }
  _pendingCandidates.clear();
}

La UI della chiamata

@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Colors.black,
    body: Stack(
      children: [
        // Video remoto a tutto schermo
        Positioned.fill(
          child: RTCVideoView(
            controller.remoteRenderer,
            objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
          ),
        ),
        // Anteprima locale in alto a destra
        Positioned(
          top: MediaQuery.of(context).padding.top + 16,
          right: 16,
          width: 110,
          height: 160,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(12),
            child: RTCVideoView(
              controller.localRenderer,
              mirror: true, // specchia la fotocamera frontale
              objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
            ),
          ),
        ),
        const Align(
          alignment: Alignment.bottomCenter,
          child: CallControlsBar(),
        ),
      ],
    ),
  );
}

Il flag mirror: true va usato solo sull'anteprima locale con fotocamera frontale: specchiare il video remoto è un errore classico che confonde chi legge testo o mostra oggetti.

Controlli della chiamata

// Mute / unmute del microfono
void toggleMic() {
  final track = _localStream!.getAudioTracks().first;
  track.enabled = !track.enabled;
}

// Spegnere la fotocamera (la traccia resta negoziata, non serve rinegoziare)
void toggleCamera() {
  final track = _localStream!.getVideoTracks().first;
  track.enabled = !track.enabled;
}

// Passare da frontale a posteriore
Future<void> switchCamera() async {
  final track = _localStream!.getVideoTracks().first;
  await Helper.switchCamera(track);
}

// Instradare l'audio sull'altoparlante
Future<void> enableSpeaker(bool on) async {
  await Helper.setSpeakerphoneOn(on);
}

Attenzione: track.enabled = false interrompe l'invio dei dati ma non spegne il LED della fotocamera né rilascia il device. Se vuoi davvero liberare l'hardware devi fermare la traccia e rinegoziare con replaceTrack.

Chiudere correttamente la chiamata

Una gestione sciatta della chiusura è la prima causa di fotocamere che restano accese e di memory leak:

Future<void> hangUp() async {
  _signaling.send({'type': 'bye'});

  for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
    await track.stop();
  }
  await _localStream?.dispose();
  _localStream = null;

  await _pc?.close();
  await _pc?.dispose();
  _pc = null;

  localRenderer.srcObject = null;
  remoteRenderer.srcObject = null;
}

Monitorare la qualità con getStats

WebRTC espone metriche dettagliate che permettono di mostrare all'utente un indicatore di qualità della rete:

Future<void> logQuality() async {
  final reports = await _pc!.getStats();
  for (final report in reports) {
    if (report.type == 'inbound-rtp' && report.values['kind'] == 'video') {
      final lost = report.values['packetsLost'];
      final jitter = report.values['jitter'];
      final fps = report.values['framesPerSecond'];
      debugPrint('Video in: lost=$lost jitter=$jitter fps=$fps');
    }
    if (report.type == 'candidate-pair' && report.values['state'] == 'succeeded') {
      debugPrint('RTT: ${report.values['currentRoundTripTime']}');
    }
  }
}

Una buona euristica: RTT sopra i 300 ms o packet loss sopra il 5% → mostra l'avviso "connessione instabile" e valuta di disattivare il video mantenendo l'audio.

Oltre il peer-to-peer: quando serve un SFU

Il modello mesh peer-to-peer funziona bene solo fino a 3-4 partecipanti: ogni peer deve inviare il proprio video a tutti gli altri, quindi l'upload cresce linearmente e la batteria si esaurisce rapidamente. Per riunioni con più persone serve un SFU (Selective Forwarding Unit): ogni client invia un solo flusso al server, che lo smista agli altri.

Opzioni open source compatibili con flutter_webrtc: LiveKit (che offre anche un SDK Flutter già pronto), mediasoup, Janus, ion-sfu. Se il progetto prevede stanze multi-utente, partire direttamente da LiveKit fa risparmiare settimane di lavoro.

Chiamate in arrivo ad app chiusa

Per un'esperienza da "vera app di telefonia" servono due pezzi aggiuntivi:

  • Android: una notifica high-priority tramite FCM con un ForegroundService di tipo microphone|camera.
  • iOS: PushKit (VoIP push) + CallKit, obbligatori dal momento che iOS non permette di risvegliare l'app con una notifica normale per mostrare una schermata di chiamata. Importante: iOS richiede che ogni push VoIP ricevuto riporti immediatamente una chiamata a CallKit, altrimenti il sistema blocca l'app.

Il pacchetto flutter_callkit_incoming astrae entrambe le piattaforme ed è il compagno naturale di flutter_webrtc.

Checklist finale

  • Chiedi i permessi di camera e microfono prima di getUserMedia, con una schermata esplicativa.
  • Configura sempre un server TURN con credenziali effimere.
  • Bufferizza i candidati ICE ricevuti prima della remote description.
  • Gestisci RTCPeerConnectionStateFailed con restartIce().
  • Ferma le tracce e chiudi la peer connection alla fine della chiamata.
  • Testa su rete mobile reale e con passaggio Wi-Fi → 4G: è lì che emergono i bug veri.
  • Non testare su emulatore: la fotocamera virtuale e lo stack di rete non sono rappresentativi.

WebRTC ha una curva di apprendimento ripida, ma flutter_webrtc offre un'API sufficientemente fedele allo standard da permettere di riutilizzare tutta la documentazione e i pattern del mondo web. Una volta compresi offer/answer e ICE, il resto è ingegneria di prodotto.