/**
* This file is part of Threema Web.
*
* Threema Web is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
* General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Threema Web. If not, see .
*/
import TaskConnectionState = threema.TaskConnectionState;
import {Logger} from 'ts-log';
import {ConfidentialIceCandidate} from '../helpers/confidential';
import {UnboundedFlowControlledDataChannel} from '../helpers/data_channel';
import {LogService} from './log';
import {TimeoutService} from './timeout';
/**
* Wrapper around the WebRTC PeerConnection.
*/
export class PeerConnectionHelper {
private static readonly CONNECTION_FAILED_TIMEOUT_MS = 15000;
// Angular services
private readonly log: Logger;
private readonly $q: ng.IQService;
private readonly $rootScope: ng.IRootScopeService;
// Custom services
private readonly config: threema.Config;
private readonly logService: LogService;
private readonly timeoutService: TimeoutService;
// WebRTC
public readonly pc: RTCPeerConnection;
private readonly webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask;
private connectionFailedTimer: ng.IPromise | null = null;
// Handed over signalling channel
private sdc: UnboundedFlowControlledDataChannel | null = null;
// Calculated connection state
public connectionState: TaskConnectionState = TaskConnectionState.New;
public onConnectionStateChange: (state: TaskConnectionState) => void = null;
constructor(
$q: ng.IQService,
$rootScope: ng.IRootScopeService,
config: threema.Config,
logService: LogService,
timeoutService: TimeoutService,
webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask,
iceServers: RTCIceServer[],
) {
this.log = logService.getLogger('PeerConnection', 'color: #fff; background-color: #3333ff');
this.log.info('Initialize WebRTC PeerConnection');
this.log.debug('ICE servers used:', [].concat(...iceServers.map((c) => c.urls)));
this.$q = $q;
this.$rootScope = $rootScope;
this.config = config;
this.logService = logService;
this.timeoutService = timeoutService;
this.webrtcTask = webrtcTask;
// Set up peer connection
this.pc = new RTCPeerConnection({iceServers: iceServers});
this.pc.onnegotiationneeded = () => {
this.log.debug('RTCPeerConnection: negotiation needed');
this.initiatorFlow()
.then(() => this.log.debug('Initiator flow done'));
};
// Handle state changes
this.pc.onconnectionstatechange = () => {
this.log.debug('Connection state change:', this.pc.connectionState);
};
this.pc.onsignalingstatechange = () => {
this.log.debug('Signaling state change:', this.pc.signalingState);
};
// Set up ICE candidate handling
this.setupIceCandidateHandling();
// Log incoming data channels
this.pc.ondatachannel = (e: RTCDataChannelEvent) => {
this.log.debug('New data channel was created:', e.channel.label);
};
}
/**
* Return the wrapped RTCPeerConnection instance.
*/
public get peerConnection(): RTCPeerConnection {
return this.pc;
}
/**
* Set up receiving / sending of ICE candidates.
*/
private setupIceCandidateHandling() {
this.log.debug('Setting up ICE candidate handling');
this.pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => {
if (e.candidate) {
this.log.debug('Gathered local ICE candidate:', new ConfidentialIceCandidate(e.candidate.candidate));
this.webrtcTask.sendCandidate({
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
});
} else {
this.log.debug('No more local ICE candidates');
}
};
this.pc.onicecandidateerror = (e: RTCPeerConnectionIceErrorEvent) => {
this.log.warn(`ICE candidate error: ${e.errorText} ` +
`(url=${e.url}, host-candidate=${e.hostCandidate}, code=${e.errorCode})`);
};
this.pc.oniceconnectionstatechange = () => {
this.log.debug('ICE connection state change:', this.pc.iceConnectionState);
this.$rootScope.$apply(() => {
// Cancel connection failed timer
if (this.connectionFailedTimer !== null) {
this.timeoutService.cancel(this.connectionFailedTimer);
this.connectionFailedTimer = null;
}
// Handle state
switch (this.pc.iceConnectionState) {
case 'new':
this.setConnectionState(TaskConnectionState.New);
break;
case 'checking':
case 'disconnected':
this.setConnectionState(TaskConnectionState.Connecting);
// Setup connection failed timer
// Note: There is no guarantee that we will end up in the 'failed' state, so we need to set up
// our own timer as well.
this.connectionFailedTimer = this.timeoutService.register(() => {
// Closing the peer connection to prevent "SURPRISE, the connection works after all!"
// situations which certainly would lead to ugly race conditions.
this.connectionFailedTimer = null;
this.log.debug('ICE connection considered failed');
this.pc.close();
}, PeerConnectionHelper.CONNECTION_FAILED_TIMEOUT_MS, true, 'connectionFailedTimer');
break;
case 'connected':
case 'completed':
this.setConnectionState(TaskConnectionState.Connected);
break;
case 'failed':
case 'closed':
this.setConnectionState(TaskConnectionState.Disconnected);
break;
default:
this.log.warn('Ignored ICE connection state change to',
this.pc.iceConnectionState);
}
});
};
this.pc.onicegatheringstatechange = () => {
this.log.debug('ICE gathering state change:', this.pc.iceGatheringState);
};
this.webrtcTask.on('candidates', (e: saltyrtc.tasks.webrtc.CandidatesEvent) => {
for (const candidateInit of e.data) {
if (candidateInit) {
this.log.debug('Adding remote ICE candidate:',
new ConfidentialIceCandidate(candidateInit.candidate));
} else {
this.log.debug('No more remote ICE candidates');
}
this.pc.addIceCandidate(candidateInit)
.catch((error) => this.log.warn('Unable to add ice candidate:', error));
}
});
}
private async initiatorFlow(): Promise {
// Send offer
const offer: RTCSessionDescriptionInit = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.log.debug('Created offer, set local description');
this.webrtcTask.sendOffer(offer);
// Receive answer
const receiveAnswer: () => Promise = () => {
return new Promise((resolve) => {
this.webrtcTask.once('answer', (e: saltyrtc.tasks.webrtc.AnswerEvent) => {
resolve(e.data);
});
});
};
const answer: RTCSessionDescriptionInit = await receiveAnswer();
await this.pc.setRemoteDescription(answer);
this.log.debug('Received answer, set remote description');
}
/**
* Initiate the handover process.
*/
public handover(): void {
if (this.sdc !== null) {
throw new Error('Handover already inintiated');
}
// Get transport link
const link: saltyrtc.tasks.webrtc.SignalingTransportLink = this.webrtcTask.getTransportLink();
// Create data channel
const dc = this.pc.createDataChannel(link.label, {
id: link.id,
negotiated: true,
ordered: true,
protocol: link.protocol,
});
dc.binaryType = 'arraybuffer';
// Wrap as an unbounded, flow-controlled data channel
this.sdc = new UnboundedFlowControlledDataChannel(dc, this.logService, this.config.TRANSPORT_LOG_LEVEL);
// Create transport handler
const self = this;
const handler = {
get maxMessageSize(): number {
return self.pc.sctp.maxMessageSize;
},
close(): void {
self.log.debug(`Signalling data channel close request`);
dc.close();
},
send(message: Uint8Array): void {
self.log.debug(`Signalling data channel outgoing signaling message of ` +
`length ${message.byteLength}`);
self.sdc.write(message);
},
};
// Bind events
dc.onopen = () => {
this.log.info(`Signalling data channel open`);
// Rebind close event
dc.onclose = () => {
this.log.info(`Signalling data channel closed`);
link.closed();
};
// Initiate handover
this.webrtcTask.handover(handler);
};
dc.onclose = () => {
this.log.error(`Signalling data channel closed`);
};
dc.onerror = (event) => {
this.log.error(`Signalling data channel error:`, event);
};
dc.onmessage = (event) => {
this.log.debug(`Signalling data channel incoming message of length ${event.data.byteLength}`);
link.receive(new Uint8Array(event.data));
};
}
/**
* Set the connection state and update listeners.
*/
private setConnectionState(state: TaskConnectionState) {
if (state !== this.connectionState) {
this.connectionState = state;
if (this.onConnectionStateChange !== null) {
this.onConnectionStateChange(state);
}
}
}
/**
* Unbind all event handler and abruptly close the peer connection.
*/
public close(): void {
this.webrtcTask.off();
this.pc.onnegotiationneeded = null;
this.pc.onconnectionstatechange = null;
this.pc.onsignalingstatechange = null;
this.pc.onicecandidate = null;
this.pc.onicecandidateerror = null;
this.pc.oniceconnectionstatechange = null;
this.pc.onicegatheringstatechange = null;
this.pc.ondatachannel = null;
this.pc.close();
}
}