port jingle rtp connection
This commit is contained in:
parent
d7ab5e1a4b
commit
eafa93d132
|
@ -109,6 +109,10 @@ dependencies {
|
||||||
// XMPP Address library
|
// XMPP Address library
|
||||||
implementation 'org.jxmpp:jxmpp-jid:1.0.3'
|
implementation 'org.jxmpp:jxmpp-jid:1.0.3'
|
||||||
|
|
||||||
|
// WebRTC
|
||||||
|
implementation 'im.conversations.webrtc:webrtc-android:104.0.0'
|
||||||
|
|
||||||
|
|
||||||
// Consistent Color Generation
|
// Consistent Color Generation
|
||||||
implementation 'org.hsluv:hsluv:0.2'
|
implementation 'org.hsluv:hsluv:0.2'
|
||||||
|
|
||||||
|
@ -116,15 +120,19 @@ dependencies {
|
||||||
// DNS library (XMPP needs to resolve SRV records)
|
// DNS library (XMPP needs to resolve SRV records)
|
||||||
implementation 'de.measite.minidns:minidns-hla:0.2.4'
|
implementation 'de.measite.minidns:minidns-hla:0.2.4'
|
||||||
|
|
||||||
|
|
||||||
// Guava
|
// Guava
|
||||||
implementation 'com.google.guava:guava:31.1-android'
|
implementation 'com.google.guava:guava:31.1-android'
|
||||||
|
|
||||||
|
|
||||||
// HTTP library
|
// HTTP library
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.10.0"
|
implementation "com.squareup.okhttp3:okhttp:4.10.0"
|
||||||
|
|
||||||
|
|
||||||
// JSON parser
|
// JSON parser
|
||||||
implementation 'com.google.code.gson:gson:2.10.1'
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
|
||||||
|
|
||||||
// logging framework + logging api
|
// logging framework + logging api
|
||||||
implementation 'org.slf4j:slf4j-api:1.7.36'
|
implementation 'org.slf4j:slf4j-api:1.7.36'
|
||||||
implementation 'com.github.tony19:logback-android:2.0.1'
|
implementation 'com.github.tony19:logback-android:2.0.1'
|
||||||
|
|
|
@ -106,6 +106,11 @@
|
||||||
<activity
|
<activity
|
||||||
android:name="im.conversations.android.ui.activity.SetupActivity"
|
android:name="im.conversations.android.ui.activity.SetupActivity"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.activity.RtpSessionActivity"
|
||||||
|
android:autoRemoveFromRecents="true"
|
||||||
|
android:launchMode="singleInstance"
|
||||||
|
android:supportsPictureInPicture="true" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
10
app/src/main/java/eu/siacs/conversations/Config.java
Normal file
10
app/src/main/java/eu/siacs/conversations/Config.java
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package eu.siacs.conversations;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
public class Config {
|
||||||
|
public static final String LOGTAG = "conversations";
|
||||||
|
public static final Uri HELP = Uri.parse("https://help.conversations.im");
|
||||||
|
public static final boolean REQUIRE_RTP_VERIFICATION =
|
||||||
|
false; // require a/v calls to be verified with OMEMO
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
package eu.siacs.conversations.generator;
|
||||||
|
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.Media;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import im.conversations.android.xmpp.manager.JingleConnectionManager;
|
||||||
|
import im.conversations.android.xmpp.model.stanza.Message;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
|
||||||
|
public final class MessageGenerator {
|
||||||
|
|
||||||
|
private MessageGenerator() {
|
||||||
|
throw new IllegalStateException("Do not instantiate me");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Message sessionProposal(
|
||||||
|
final JingleConnectionManager.RtpSessionProposal proposal) {
|
||||||
|
final Message packet = new Message(Message.Type.CHAT); // we want to carbon copy those
|
||||||
|
packet.setTo(proposal.with);
|
||||||
|
packet.setId(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX + proposal.sessionId);
|
||||||
|
final Element propose = packet.addChild("propose", Namespace.JINGLE_MESSAGE);
|
||||||
|
propose.setAttribute("id", proposal.sessionId);
|
||||||
|
for (final Media media : proposal.media) {
|
||||||
|
propose.addChild("description", Namespace.JINGLE_APPS_RTP)
|
||||||
|
.setAttribute("media", media.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
packet.addChild("request", "urn:xmpp:receipts");
|
||||||
|
packet.addChild("store", "urn:xmpp:hints");
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Message sessionRetract(
|
||||||
|
final JingleConnectionManager.RtpSessionProposal proposal) {
|
||||||
|
final Message packet = new Message(Message.Type.CHAT); // we want to carbon copy those
|
||||||
|
packet.setTo(proposal.with);
|
||||||
|
final Element propose = packet.addChild("retract", Namespace.JINGLE_MESSAGE);
|
||||||
|
propose.setAttribute("id", proposal.sessionId);
|
||||||
|
propose.addChild("description", Namespace.JINGLE_APPS_RTP);
|
||||||
|
packet.addChild("store", "urn:xmpp:hints");
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Message sessionReject(final Jid with, final String sessionId) {
|
||||||
|
final Message packet = new Message(Message.Type.CHAT); // we want to carbon copy those
|
||||||
|
packet.setTo(with);
|
||||||
|
final Element propose = packet.addChild("reject", Namespace.JINGLE_MESSAGE);
|
||||||
|
propose.setAttribute("id", sessionId);
|
||||||
|
propose.addChild("description", Namespace.JINGLE_APPS_RTP);
|
||||||
|
packet.addChild("store", "urn:xmpp:hints");
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,660 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2014 The WebRTC Project Authors. All rights reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by a BSD-style license
|
||||||
|
* that can be found in the LICENSE file in the root of the source
|
||||||
|
* tree. An additional intellectual property rights grant can be found
|
||||||
|
* in the file PATENTS. All contributing project authors may
|
||||||
|
* be found in the AUTHORS file in the root of the source tree.
|
||||||
|
*/
|
||||||
|
package eu.siacs.conversations.services;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.AudioDeviceInfo;
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.utils.AppRTCUtils;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.Media;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import org.webrtc.ThreadUtils;
|
||||||
|
|
||||||
|
/** AppRTCAudioManager manages all audio related parts of the AppRTC demo. */
|
||||||
|
public class AppRTCAudioManager {
|
||||||
|
|
||||||
|
private static CountDownLatch microphoneLatch;
|
||||||
|
|
||||||
|
private final Context apprtcContext;
|
||||||
|
// Contains speakerphone setting: auto, true or false
|
||||||
|
@Nullable private SpeakerPhonePreference speakerPhonePreference;
|
||||||
|
// Handles all tasks related to Bluetooth headset devices.
|
||||||
|
private final AppRTCBluetoothManager bluetoothManager;
|
||||||
|
@Nullable private final AudioManager audioManager;
|
||||||
|
@Nullable private AudioManagerEvents audioManagerEvents;
|
||||||
|
private AudioManagerState amState;
|
||||||
|
private boolean savedIsSpeakerPhoneOn;
|
||||||
|
private boolean savedIsMicrophoneMute;
|
||||||
|
private boolean hasWiredHeadset;
|
||||||
|
// Default audio device; speaker phone for video calls or earpiece for audio
|
||||||
|
// only calls.
|
||||||
|
private AudioDevice defaultAudioDevice;
|
||||||
|
// Contains the currently selected audio device.
|
||||||
|
// This device is changed automatically using a certain scheme where e.g.
|
||||||
|
// a wired headset "wins" over speaker phone. It is also possible for a
|
||||||
|
// user to explicitly select a device (and overrid any predefined scheme).
|
||||||
|
// See |userSelectedAudioDevice| for details.
|
||||||
|
private AudioDevice selectedAudioDevice;
|
||||||
|
// Contains the user-selected audio device which overrides the predefined
|
||||||
|
// selection scheme.
|
||||||
|
// TODO(henrika): always set to AudioDevice.NONE today. Add support for
|
||||||
|
// explicit selection based on choice by userSelectedAudioDevice.
|
||||||
|
private AudioDevice userSelectedAudioDevice;
|
||||||
|
// Proximity sensor object. It measures the proximity of an object in cm
|
||||||
|
// relative to the view screen of a device and can therefore be used to
|
||||||
|
// assist device switching (close to ear <=> use headset earpiece if
|
||||||
|
// available, far from ear <=> use speaker phone).
|
||||||
|
@Nullable private AppRTCProximitySensor proximitySensor;
|
||||||
|
// Contains a list of available audio devices. A Set collection is used to
|
||||||
|
// avoid duplicate elements.
|
||||||
|
private Set<AudioDevice> audioDevices = new HashSet<>();
|
||||||
|
// Broadcast receiver for wired headset intent broadcasts.
|
||||||
|
private final BroadcastReceiver wiredHeadsetReceiver;
|
||||||
|
// Callback method for changes in audio focus.
|
||||||
|
@Nullable private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
|
||||||
|
|
||||||
|
private AppRTCAudioManager(
|
||||||
|
Context context, final SpeakerPhonePreference speakerPhonePreference) {
|
||||||
|
Log.d(Config.LOGTAG, "ctor");
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
apprtcContext = context;
|
||||||
|
audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
|
||||||
|
bluetoothManager = AppRTCBluetoothManager.create(context, this);
|
||||||
|
wiredHeadsetReceiver = new WiredHeadsetReceiver();
|
||||||
|
amState = AudioManagerState.UNINITIALIZED;
|
||||||
|
this.speakerPhonePreference = speakerPhonePreference;
|
||||||
|
if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
|
||||||
|
defaultAudioDevice = AudioDevice.EARPIECE;
|
||||||
|
} else {
|
||||||
|
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
|
||||||
|
}
|
||||||
|
// Create and initialize the proximity sensor.
|
||||||
|
// Tablet devices (e.g. Nexus 7) does not support proximity sensors.
|
||||||
|
// Note that, the sensor will not be active until start() has been called.
|
||||||
|
proximitySensor =
|
||||||
|
AppRTCProximitySensor.create(
|
||||||
|
context,
|
||||||
|
// This method will be called each time a state change is detected.
|
||||||
|
// Example: user holds his hand over the device (closer than ~5 cm),
|
||||||
|
// or removes his hand from the device.
|
||||||
|
this::onProximitySensorChangedState);
|
||||||
|
Log.d(Config.LOGTAG, "defaultAudioDevice: " + defaultAudioDevice);
|
||||||
|
AppRTCUtils.logDeviceInfo(Config.LOGTAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void switchSpeakerPhonePreference(final SpeakerPhonePreference speakerPhonePreference) {
|
||||||
|
this.speakerPhonePreference = speakerPhonePreference;
|
||||||
|
if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
|
||||||
|
defaultAudioDevice = AudioDevice.EARPIECE;
|
||||||
|
} else {
|
||||||
|
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
|
||||||
|
}
|
||||||
|
updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construction. */
|
||||||
|
public static AppRTCAudioManager create(
|
||||||
|
Context context, SpeakerPhonePreference speakerPhonePreference) {
|
||||||
|
return new AppRTCAudioManager(context, speakerPhonePreference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isMicrophoneAvailable() {
|
||||||
|
microphoneLatch = new CountDownLatch(1);
|
||||||
|
AudioRecord audioRecord = null;
|
||||||
|
boolean available = true;
|
||||||
|
try {
|
||||||
|
final int sampleRate = 44100;
|
||||||
|
final int channel = AudioFormat.CHANNEL_IN_MONO;
|
||||||
|
final int format = AudioFormat.ENCODING_PCM_16BIT;
|
||||||
|
final int bufferSize = AudioRecord.getMinBufferSize(sampleRate, channel, format);
|
||||||
|
audioRecord =
|
||||||
|
new AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC, sampleRate, channel, format, bufferSize);
|
||||||
|
audioRecord.startRecording();
|
||||||
|
final short[] buffer = new short[bufferSize];
|
||||||
|
final int audioStatus = audioRecord.read(buffer, 0, bufferSize);
|
||||||
|
if (audioStatus == AudioRecord.ERROR_INVALID_OPERATION
|
||||||
|
|| audioStatus == AudioRecord.STATE_UNINITIALIZED) available = false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
available = false;
|
||||||
|
} finally {
|
||||||
|
release(audioRecord);
|
||||||
|
}
|
||||||
|
microphoneLatch.countDown();
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void release(final AudioRecord audioRecord) {
|
||||||
|
if (audioRecord == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
audioRecord.release();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is called when the proximity sensor reports a state change, e.g. from "NEAR to
|
||||||
|
* FAR" or from "FAR to NEAR".
|
||||||
|
*/
|
||||||
|
private void onProximitySensorChangedState() {
|
||||||
|
if (speakerPhonePreference != SpeakerPhonePreference.AUTO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The proximity sensor should only be activated when there are exactly two
|
||||||
|
// available audio devices.
|
||||||
|
if (audioDevices.size() == 2
|
||||||
|
&& audioDevices.contains(AppRTCAudioManager.AudioDevice.EARPIECE)
|
||||||
|
&& audioDevices.contains(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE)) {
|
||||||
|
if (proximitySensor.sensorReportsNearState()) {
|
||||||
|
// Sensor reports that a "handset is being held up to a person's ear",
|
||||||
|
// or "something is covering the light sensor".
|
||||||
|
setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.EARPIECE);
|
||||||
|
} else {
|
||||||
|
// Sensor reports that a "handset is removed from a person's ear", or
|
||||||
|
// "the light sensor is no longer covered".
|
||||||
|
setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public void start(AudioManagerEvents audioManagerEvents) {
|
||||||
|
Log.d(Config.LOGTAG, AppRTCAudioManager.class.getName() + ".start()");
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
if (amState == AudioManagerState.RUNNING) {
|
||||||
|
Log.e(Config.LOGTAG, "AudioManager is already active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
awaitMicrophoneLatch();
|
||||||
|
this.audioManagerEvents = audioManagerEvents;
|
||||||
|
amState = AudioManagerState.RUNNING;
|
||||||
|
// Store current audio state so we can restore it when stop() is called.
|
||||||
|
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn();
|
||||||
|
savedIsMicrophoneMute = audioManager.isMicrophoneMute();
|
||||||
|
hasWiredHeadset = hasWiredHeadset();
|
||||||
|
// Create an AudioManager.OnAudioFocusChangeListener instance.
|
||||||
|
audioFocusChangeListener =
|
||||||
|
new AudioManager.OnAudioFocusChangeListener() {
|
||||||
|
// Called on the listener to notify if the audio focus for this listener has
|
||||||
|
// been changed.
|
||||||
|
// The |focusChange| value indicates whether the focus was gained, whether the
|
||||||
|
// focus was lost,
|
||||||
|
// and whether that loss is transient, or whether the new focus holder will hold
|
||||||
|
// it for an
|
||||||
|
// unknown amount of time.
|
||||||
|
// TODO(henrika): possibly extend support of handling audio-focus changes. Only
|
||||||
|
// contains
|
||||||
|
// logging for now.
|
||||||
|
@Override
|
||||||
|
public void onAudioFocusChange(int focusChange) {
|
||||||
|
final String typeOfChange;
|
||||||
|
switch (focusChange) {
|
||||||
|
case AudioManager.AUDIOFOCUS_GAIN:
|
||||||
|
typeOfChange = "AUDIOFOCUS_GAIN";
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
|
||||||
|
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT";
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE:
|
||||||
|
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE";
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:
|
||||||
|
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK";
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS:
|
||||||
|
typeOfChange = "AUDIOFOCUS_LOSS";
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||||
|
typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT";
|
||||||
|
break;
|
||||||
|
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||||
|
typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
typeOfChange = "AUDIOFOCUS_INVALID";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "onAudioFocusChange: " + typeOfChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Request audio playout focus (without ducking) and install listener for changes in focus.
|
||||||
|
int result =
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
audioFocusChangeListener,
|
||||||
|
AudioManager.STREAM_VOICE_CALL,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
|
||||||
|
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||||
|
Log.d(Config.LOGTAG, "Audio focus request granted for VOICE_CALL streams");
|
||||||
|
} else {
|
||||||
|
Log.e(Config.LOGTAG, "Audio focus request failed");
|
||||||
|
}
|
||||||
|
// Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
|
||||||
|
// required to be in this mode when playout and/or recording starts for
|
||||||
|
// best possible VoIP performance.
|
||||||
|
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||||
|
// Always disable microphone mute during a WebRTC call.
|
||||||
|
setMicrophoneMute(false);
|
||||||
|
// Set initial device states.
|
||||||
|
userSelectedAudioDevice = AudioDevice.NONE;
|
||||||
|
selectedAudioDevice = AudioDevice.NONE;
|
||||||
|
audioDevices.clear();
|
||||||
|
// Initialize and start Bluetooth if a BT device is available or initiate
|
||||||
|
// detection of new (enabled) BT devices.
|
||||||
|
bluetoothManager.start();
|
||||||
|
// Do initial selection of audio device. This setting can later be changed
|
||||||
|
// either by adding/removing a BT or wired headset or by covering/uncovering
|
||||||
|
// the proximity sensor.
|
||||||
|
updateAudioDeviceState();
|
||||||
|
// Register receiver for broadcast intents related to adding/removing a
|
||||||
|
// wired headset.
|
||||||
|
registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
|
||||||
|
Log.d(Config.LOGTAG, "AudioManager started");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitMicrophoneLatch() {
|
||||||
|
final CountDownLatch latch = microphoneLatch;
|
||||||
|
if (latch == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
latch.await();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public void stop() {
|
||||||
|
Log.d(Config.LOGTAG, AppRTCAudioManager.class.getName() + ".stop()");
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
if (amState != AudioManagerState.RUNNING) {
|
||||||
|
Log.e(Config.LOGTAG, "Trying to stop AudioManager in incorrect state: " + amState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amState = AudioManagerState.UNINITIALIZED;
|
||||||
|
unregisterReceiver(wiredHeadsetReceiver);
|
||||||
|
bluetoothManager.stop();
|
||||||
|
// Restore previously stored audio states.
|
||||||
|
setSpeakerphoneOn(savedIsSpeakerPhoneOn);
|
||||||
|
setMicrophoneMute(savedIsMicrophoneMute);
|
||||||
|
audioManager.setMode(AudioManager.MODE_NORMAL);
|
||||||
|
// Abandon audio focus. Gives the previous focus owner, if any, focus.
|
||||||
|
audioManager.abandonAudioFocus(audioFocusChangeListener);
|
||||||
|
audioFocusChangeListener = null;
|
||||||
|
Log.d(Config.LOGTAG, "Abandoned audio focus for VOICE_CALL streams");
|
||||||
|
if (proximitySensor != null) {
|
||||||
|
proximitySensor.stop();
|
||||||
|
proximitySensor = null;
|
||||||
|
}
|
||||||
|
audioManagerEvents = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes selection of the currently active audio device. */
|
||||||
|
private void setAudioDeviceInternal(AudioDevice device) {
|
||||||
|
Log.d(Config.LOGTAG, "setAudioDeviceInternal(device=" + device + ")");
|
||||||
|
AppRTCUtils.assertIsTrue(audioDevices.contains(device));
|
||||||
|
switch (device) {
|
||||||
|
case SPEAKER_PHONE:
|
||||||
|
setSpeakerphoneOn(true);
|
||||||
|
break;
|
||||||
|
case EARPIECE:
|
||||||
|
case WIRED_HEADSET:
|
||||||
|
case BLUETOOTH:
|
||||||
|
setSpeakerphoneOn(false);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Log.e(Config.LOGTAG, "Invalid audio device selection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
selectedAudioDevice = device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes default audio device. TODO(henrika): add usage of this method in the AppRTCMobile
|
||||||
|
* client.
|
||||||
|
*/
|
||||||
|
public void setDefaultAudioDevice(AudioDevice defaultDevice) {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
switch (defaultDevice) {
|
||||||
|
case SPEAKER_PHONE:
|
||||||
|
defaultAudioDevice = defaultDevice;
|
||||||
|
break;
|
||||||
|
case EARPIECE:
|
||||||
|
if (hasEarpiece()) {
|
||||||
|
defaultAudioDevice = defaultDevice;
|
||||||
|
} else {
|
||||||
|
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Log.e(Config.LOGTAG, "Invalid default audio device selection");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "setDefaultAudioDevice(device=" + defaultAudioDevice + ")");
|
||||||
|
updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes selection of the currently active audio device. */
|
||||||
|
public void selectAudioDevice(AudioDevice device) {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
if (!audioDevices.contains(device)) {
|
||||||
|
Log.e(Config.LOGTAG, "Can not select " + device + " from available " + audioDevices);
|
||||||
|
}
|
||||||
|
userSelectedAudioDevice = device;
|
||||||
|
updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns current set of available/selectable audio devices. */
|
||||||
|
public Set<AudioDevice> getAudioDevices() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
return Collections.unmodifiableSet(new HashSet<>(audioDevices));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the currently selected audio device. */
|
||||||
|
public AudioDevice getSelectedAudioDevice() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
return selectedAudioDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for receiver registration. */
|
||||||
|
private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
|
||||||
|
apprtcContext.registerReceiver(receiver, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for unregistration of an existing receiver. */
|
||||||
|
private void unregisterReceiver(BroadcastReceiver receiver) {
|
||||||
|
apprtcContext.unregisterReceiver(receiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the speaker phone mode. */
|
||||||
|
private void setSpeakerphoneOn(boolean on) {
|
||||||
|
boolean wasOn = audioManager.isSpeakerphoneOn();
|
||||||
|
if (wasOn == on) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioManager.setSpeakerphoneOn(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the microphone mute state. */
|
||||||
|
private void setMicrophoneMute(boolean on) {
|
||||||
|
boolean wasMuted = audioManager.isMicrophoneMute();
|
||||||
|
if (wasMuted == on) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioManager.setMicrophoneMute(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the current earpiece state. */
|
||||||
|
private boolean hasEarpiece() {
|
||||||
|
return apprtcContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a wired headset is connected or not. This is not a valid indication that audio
|
||||||
|
* playback is actually over the wired headset as audio routing depends on other conditions. We
|
||||||
|
* only use it as an early indicator (during initialization) of an attached wired headset.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
private boolean hasWiredHeadset() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||||
|
return audioManager.isWiredHeadsetOn();
|
||||||
|
} else {
|
||||||
|
final AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
|
||||||
|
for (AudioDeviceInfo device : devices) {
|
||||||
|
final int type = device.getType();
|
||||||
|
if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
|
||||||
|
Log.d(Config.LOGTAG, "hasWiredHeadset: found wired headset");
|
||||||
|
return true;
|
||||||
|
} else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
|
||||||
|
Log.d(Config.LOGTAG, "hasWiredHeadset: found USB audio device");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates list of possible audio devices and make new device selection. TODO(henrika): add unit
|
||||||
|
* test to verify all state transitions.
|
||||||
|
*/
|
||||||
|
public void updateAudioDeviceState() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"--- updateAudioDeviceState: "
|
||||||
|
+ "wired headset="
|
||||||
|
+ hasWiredHeadset
|
||||||
|
+ ", "
|
||||||
|
+ "BT state="
|
||||||
|
+ bluetoothManager.getState());
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"Device status: "
|
||||||
|
+ "available="
|
||||||
|
+ audioDevices
|
||||||
|
+ ", "
|
||||||
|
+ "selected="
|
||||||
|
+ selectedAudioDevice
|
||||||
|
+ ", "
|
||||||
|
+ "user selected="
|
||||||
|
+ userSelectedAudioDevice);
|
||||||
|
// Check if any Bluetooth headset is connected. The internal BT state will
|
||||||
|
// change accordingly.
|
||||||
|
// TODO(henrika): perhaps wrap required state into BT manager.
|
||||||
|
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|
||||||
|
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
|
||||||
|
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_DISCONNECTING) {
|
||||||
|
bluetoothManager.updateDevice();
|
||||||
|
}
|
||||||
|
// Update the set of available audio devices.
|
||||||
|
Set<AudioDevice> newAudioDevices = new HashSet<>();
|
||||||
|
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
|
||||||
|
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
|
||||||
|
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE) {
|
||||||
|
newAudioDevices.add(AudioDevice.BLUETOOTH);
|
||||||
|
}
|
||||||
|
if (hasWiredHeadset) {
|
||||||
|
// If a wired headset is connected, then it is the only possible option.
|
||||||
|
newAudioDevices.add(AudioDevice.WIRED_HEADSET);
|
||||||
|
} else {
|
||||||
|
// No wired headset, hence the audio-device list can contain speaker
|
||||||
|
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
|
||||||
|
newAudioDevices.add(AudioDevice.SPEAKER_PHONE);
|
||||||
|
if (hasEarpiece()) {
|
||||||
|
newAudioDevices.add(AudioDevice.EARPIECE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Store state which is set to true if the device list has changed.
|
||||||
|
boolean audioDeviceSetUpdated = !audioDevices.equals(newAudioDevices);
|
||||||
|
// Update the existing audio device set.
|
||||||
|
audioDevices = newAudioDevices;
|
||||||
|
// Correct user selected audio devices if needed.
|
||||||
|
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
|
||||||
|
&& userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
|
||||||
|
// If BT is not available, it can't be the user selection.
|
||||||
|
userSelectedAudioDevice = AudioDevice.NONE;
|
||||||
|
}
|
||||||
|
if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
|
||||||
|
// If user selected speaker phone, but then plugged wired headset then make
|
||||||
|
// wired headset as user selected device.
|
||||||
|
userSelectedAudioDevice = AudioDevice.WIRED_HEADSET;
|
||||||
|
}
|
||||||
|
if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
|
||||||
|
// If user selected wired headset, but then unplugged wired headset then make
|
||||||
|
// speaker phone as user selected device.
|
||||||
|
userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE;
|
||||||
|
}
|
||||||
|
// Need to start Bluetooth if it is available and user either selected it explicitly or
|
||||||
|
// user did not select any output device.
|
||||||
|
boolean needBluetoothAudioStart =
|
||||||
|
bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|
||||||
|
&& (userSelectedAudioDevice == AudioDevice.NONE
|
||||||
|
|| userSelectedAudioDevice == AudioDevice.BLUETOOTH);
|
||||||
|
// Need to stop Bluetooth audio if user selected different device and
|
||||||
|
// Bluetooth SCO connection is established or in the process.
|
||||||
|
boolean needBluetoothAudioStop =
|
||||||
|
(bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
|
||||||
|
|| bluetoothManager.getState()
|
||||||
|
== AppRTCBluetoothManager.State.SCO_CONNECTING)
|
||||||
|
&& (userSelectedAudioDevice != AudioDevice.NONE
|
||||||
|
&& userSelectedAudioDevice != AudioDevice.BLUETOOTH);
|
||||||
|
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|
||||||
|
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
|
||||||
|
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"Need BT audio: start="
|
||||||
|
+ needBluetoothAudioStart
|
||||||
|
+ ", "
|
||||||
|
+ "stop="
|
||||||
|
+ needBluetoothAudioStop
|
||||||
|
+ ", "
|
||||||
|
+ "BT state="
|
||||||
|
+ bluetoothManager.getState());
|
||||||
|
}
|
||||||
|
// Start or stop Bluetooth SCO connection given states set earlier.
|
||||||
|
if (needBluetoothAudioStop) {
|
||||||
|
bluetoothManager.stopScoAudio();
|
||||||
|
bluetoothManager.updateDevice();
|
||||||
|
}
|
||||||
|
if (needBluetoothAudioStart && !needBluetoothAudioStop) {
|
||||||
|
// Attempt to start Bluetooth SCO audio (takes a few second to start).
|
||||||
|
if (!bluetoothManager.startScoAudio()) {
|
||||||
|
// Remove BLUETOOTH from list of available devices since SCO failed.
|
||||||
|
audioDevices.remove(AudioDevice.BLUETOOTH);
|
||||||
|
audioDeviceSetUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update selected audio device.
|
||||||
|
final AudioDevice newAudioDevice;
|
||||||
|
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
|
||||||
|
// If a Bluetooth is connected, then it should be used as output audio
|
||||||
|
// device. Note that it is not sufficient that a headset is available;
|
||||||
|
// an active SCO channel must also be up and running.
|
||||||
|
newAudioDevice = AudioDevice.BLUETOOTH;
|
||||||
|
} else if (hasWiredHeadset) {
|
||||||
|
// If a wired headset is connected, but Bluetooth is not, then wired headset is used as
|
||||||
|
// audio device.
|
||||||
|
newAudioDevice = AudioDevice.WIRED_HEADSET;
|
||||||
|
} else {
|
||||||
|
// No wired headset and no Bluetooth, hence the audio-device list can contain speaker
|
||||||
|
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
|
||||||
|
// |defaultAudioDevice| contains either AudioDevice.SPEAKER_PHONE or
|
||||||
|
// AudioDevice.EARPIECE
|
||||||
|
// depending on the user's selection.
|
||||||
|
newAudioDevice = defaultAudioDevice;
|
||||||
|
}
|
||||||
|
// Switch to new device but only if there has been any changes.
|
||||||
|
if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) {
|
||||||
|
// Do the required device switch.
|
||||||
|
setAudioDeviceInternal(newAudioDevice);
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"New device status: "
|
||||||
|
+ "available="
|
||||||
|
+ audioDevices
|
||||||
|
+ ", "
|
||||||
|
+ "selected="
|
||||||
|
+ newAudioDevice);
|
||||||
|
if (audioManagerEvents != null) {
|
||||||
|
// Notify a listening client that audio device has been changed.
|
||||||
|
audioManagerEvents.onAudioDeviceChanged(selectedAudioDevice, audioDevices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "--- updateAudioDeviceState done");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AudioDevice is the names of possible audio devices that we currently support. */
|
||||||
|
public enum AudioDevice {
|
||||||
|
SPEAKER_PHONE,
|
||||||
|
WIRED_HEADSET,
|
||||||
|
EARPIECE,
|
||||||
|
BLUETOOTH,
|
||||||
|
NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AudioManager state. */
|
||||||
|
public enum AudioManagerState {
|
||||||
|
UNINITIALIZED,
|
||||||
|
PREINITIALIZED,
|
||||||
|
RUNNING,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SpeakerPhonePreference {
|
||||||
|
AUTO,
|
||||||
|
EARPIECE,
|
||||||
|
SPEAKER;
|
||||||
|
|
||||||
|
public static SpeakerPhonePreference of(final Set<Media> media) {
|
||||||
|
if (media.contains(Media.VIDEO)) {
|
||||||
|
return SPEAKER;
|
||||||
|
} else {
|
||||||
|
return EARPIECE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selected audio device change event. */
|
||||||
|
public interface AudioManagerEvents {
|
||||||
|
// Callback fired once audio device is changed or list of available audio devices changed.
|
||||||
|
void onAudioDeviceChanged(
|
||||||
|
AudioDevice selectedAudioDevice, Set<AudioDevice> availableAudioDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Receiver which handles changes in wired headset availability. */
|
||||||
|
private class WiredHeadsetReceiver extends BroadcastReceiver {
|
||||||
|
private static final int STATE_UNPLUGGED = 0;
|
||||||
|
private static final int STATE_PLUGGED = 1;
|
||||||
|
private static final int HAS_NO_MIC = 0;
|
||||||
|
private static final int HAS_MIC = 1;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
int state = intent.getIntExtra("state", STATE_UNPLUGGED);
|
||||||
|
int microphone = intent.getIntExtra("microphone", HAS_NO_MIC);
|
||||||
|
String name = intent.getStringExtra("name");
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"WiredHeadsetReceiver.onReceive"
|
||||||
|
+ AppRTCUtils.getThreadInfo()
|
||||||
|
+ ": "
|
||||||
|
+ "a="
|
||||||
|
+ intent.getAction()
|
||||||
|
+ ", s="
|
||||||
|
+ (state == STATE_UNPLUGGED ? "unplugged" : "plugged")
|
||||||
|
+ ", m="
|
||||||
|
+ (microphone == HAS_MIC ? "mic" : "no mic")
|
||||||
|
+ ", n="
|
||||||
|
+ name
|
||||||
|
+ ", sb="
|
||||||
|
+ isInitialStickyBroadcast());
|
||||||
|
hasWiredHeadset = (state == STATE_PLUGGED);
|
||||||
|
updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,570 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2016 The WebRTC Project Authors. All rights reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by a BSD-style license
|
||||||
|
* that can be found in the LICENSE file in the root of the source
|
||||||
|
* tree. An additional intellectual property rights grant can be found
|
||||||
|
* in the file PATENTS. All contributing project authors may
|
||||||
|
* be found in the AUTHORS file in the root of the source tree.
|
||||||
|
*/
|
||||||
|
package eu.siacs.conversations.services;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothDevice;
|
||||||
|
import android.bluetooth.BluetoothHeadset;
|
||||||
|
import android.bluetooth.BluetoothProfile;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.utils.AppRTCUtils;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import org.webrtc.ThreadUtils;
|
||||||
|
|
||||||
|
/** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */
|
||||||
|
public class AppRTCBluetoothManager {
|
||||||
|
// Timeout interval for starting or stopping audio to a Bluetooth SCO device.
|
||||||
|
private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000;
|
||||||
|
// Maximum number of SCO connection attempts.
|
||||||
|
private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
|
||||||
|
private final Context apprtcContext;
|
||||||
|
private final AppRTCAudioManager apprtcAudioManager;
|
||||||
|
@Nullable private final AudioManager audioManager;
|
||||||
|
private final Handler handler;
|
||||||
|
private final BluetoothProfile.ServiceListener bluetoothServiceListener;
|
||||||
|
private final BroadcastReceiver bluetoothHeadsetReceiver;
|
||||||
|
int scoConnectionAttempts;
|
||||||
|
private State bluetoothState;
|
||||||
|
@Nullable private BluetoothAdapter bluetoothAdapter;
|
||||||
|
@Nullable private BluetoothHeadset bluetoothHeadset;
|
||||||
|
@Nullable private BluetoothDevice bluetoothDevice;
|
||||||
|
// Runs when the Bluetooth timeout expires. We use that timeout after calling
|
||||||
|
// startScoAudio() or stopScoAudio() because we're not guaranteed to get a
|
||||||
|
// callback after those calls.
|
||||||
|
private final Runnable bluetoothTimeoutRunnable =
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
bluetoothTimeout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioManager) {
|
||||||
|
Log.d(Config.LOGTAG, "ctor");
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
apprtcContext = context;
|
||||||
|
apprtcAudioManager = audioManager;
|
||||||
|
this.audioManager = getAudioManager(context);
|
||||||
|
bluetoothState = State.UNINITIALIZED;
|
||||||
|
bluetoothServiceListener = new BluetoothServiceListener();
|
||||||
|
bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver();
|
||||||
|
handler = new Handler(Looper.getMainLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construction. */
|
||||||
|
static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audioManager) {
|
||||||
|
Log.d(Config.LOGTAG, "create" + AppRTCUtils.getThreadInfo());
|
||||||
|
return new AppRTCBluetoothManager(context, audioManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the internal state. */
|
||||||
|
public State getState() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
return bluetoothState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates components required to detect Bluetooth devices and to enable BT SCO (audio is
|
||||||
|
* routed via BT SCO) for the headset profile. The end state will be HEADSET_UNAVAILABLE but a
|
||||||
|
* state machine has started which will start a state change sequence where the final outcome
|
||||||
|
* depends on if/when the BT headset is enabled. Example of state change sequence when start()
|
||||||
|
* is called while BT device is connected and enabled: UNINITIALIZED --> HEADSET_UNAVAILABLE -->
|
||||||
|
* HEADSET_AVAILABLE --> SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
|
||||||
|
* Note that the AppRTCAudioManager is also involved in driving this state change.
|
||||||
|
*/
|
||||||
|
public void start() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
if (bluetoothState != State.UNINITIALIZED) {
|
||||||
|
Log.w(Config.LOGTAG, "Invalid BT state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bluetoothHeadset = null;
|
||||||
|
bluetoothDevice = null;
|
||||||
|
scoConnectionAttempts = 0;
|
||||||
|
// Get a handle to the default local Bluetooth adapter.
|
||||||
|
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
|
||||||
|
if (bluetoothAdapter == null) {
|
||||||
|
Log.w(Config.LOGTAG, "Device does not support Bluetooth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ensure that the device supports use of BT SCO audio for off call use cases.
|
||||||
|
if (this.audioManager == null || !audioManager.isBluetoothScoAvailableOffCall()) {
|
||||||
|
Log.e(Config.LOGTAG, "Bluetooth SCO audio is not available off call");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Establish a connection to the HEADSET profile (includes both Bluetooth Headset and
|
||||||
|
// Hands-Free) proxy object and install a listener.
|
||||||
|
if (!getBluetoothProfileProxy(
|
||||||
|
apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET)) {
|
||||||
|
Log.e(Config.LOGTAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Register receivers for BluetoothHeadset change notifications.
|
||||||
|
IntentFilter bluetoothHeadsetFilter = new IntentFilter();
|
||||||
|
// Register receiver for change in connection state of the Headset profile.
|
||||||
|
bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
|
||||||
|
// Register receiver for change in audio connection state of the Headset profile.
|
||||||
|
bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
|
||||||
|
registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter);
|
||||||
|
if (hasBluetoothConnectPermission()) {
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"HEADSET profile state: "
|
||||||
|
+ stateToString(
|
||||||
|
bluetoothAdapter.getProfileConnectionState(
|
||||||
|
BluetoothProfile.HEADSET)));
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "Bluetooth proxy for headset profile has started");
|
||||||
|
bluetoothState = State.HEADSET_UNAVAILABLE;
|
||||||
|
Log.d(Config.LOGTAG, "start done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops and closes all components related to Bluetooth audio. */
|
||||||
|
public void stop() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(Config.LOGTAG, "stop: BT state=" + bluetoothState);
|
||||||
|
if (bluetoothAdapter == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Stop BT SCO connection with remote device if needed.
|
||||||
|
stopScoAudio();
|
||||||
|
// Close down remaining BT resources.
|
||||||
|
if (bluetoothState == State.UNINITIALIZED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterReceiver(bluetoothHeadsetReceiver);
|
||||||
|
cancelTimer();
|
||||||
|
if (bluetoothHeadset != null) {
|
||||||
|
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
|
||||||
|
bluetoothHeadset = null;
|
||||||
|
}
|
||||||
|
bluetoothAdapter = null;
|
||||||
|
bluetoothDevice = null;
|
||||||
|
bluetoothState = State.UNINITIALIZED;
|
||||||
|
Log.d(Config.LOGTAG, "stop done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts Bluetooth SCO connection with remote device. Note that the phone application always
|
||||||
|
* has the priority on the usage of the SCO connection for telephony. If this method is called
|
||||||
|
* while the phone is in call it will be ignored. Similarly, if a call is received or sent while
|
||||||
|
* an application is using the SCO connection, the connection will be lost for the application
|
||||||
|
* and NOT returned automatically when the call ends. Also note that: up to and including API
|
||||||
|
* version JELLY_BEAN_MR1, this method initiates a virtual voice call to the Bluetooth headset.
|
||||||
|
* After API version JELLY_BEAN_MR2 only a raw SCO audio connection is established.
|
||||||
|
* TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and
|
||||||
|
* higher. It might be required to initiates a virtual voice call since many devices do not
|
||||||
|
* accept SCO audio without a "call".
|
||||||
|
*/
|
||||||
|
public boolean startScoAudio() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"startSco: BT state="
|
||||||
|
+ bluetoothState
|
||||||
|
+ ", "
|
||||||
|
+ "attempts: "
|
||||||
|
+ scoConnectionAttempts
|
||||||
|
+ ", "
|
||||||
|
+ "SCO is on: "
|
||||||
|
+ isScoOn());
|
||||||
|
if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
|
||||||
|
Log.e(Config.LOGTAG, "BT SCO connection fails - no more attempts");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bluetoothState != State.HEADSET_AVAILABLE) {
|
||||||
|
Log.e(Config.LOGTAG, "BT SCO connection fails - no headset available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
|
||||||
|
Log.d(Config.LOGTAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED...");
|
||||||
|
// The SCO connection establishment can take several seconds, hence we cannot rely on the
|
||||||
|
// connection to be available when the method returns but instead register to receive the
|
||||||
|
// intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be
|
||||||
|
// SCO_AUDIO_STATE_CONNECTED.
|
||||||
|
bluetoothState = State.SCO_CONNECTING;
|
||||||
|
audioManager.startBluetoothSco();
|
||||||
|
audioManager.setBluetoothScoOn(true);
|
||||||
|
scoConnectionAttempts++;
|
||||||
|
startTimer();
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"startScoAudio done: BT state="
|
||||||
|
+ bluetoothState
|
||||||
|
+ ", "
|
||||||
|
+ "SCO is on: "
|
||||||
|
+ isScoOn());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops Bluetooth SCO connection with remote device. */
|
||||||
|
public void stopScoAudio() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"stopScoAudio: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn());
|
||||||
|
if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelTimer();
|
||||||
|
audioManager.stopBluetoothSco();
|
||||||
|
audioManager.setBluetoothScoOn(false);
|
||||||
|
bluetoothState = State.SCO_DISCONNECTING;
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"stopScoAudio done: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the BluetoothHeadset proxy object (controls the Bluetooth Headset Service via IPC) to
|
||||||
|
* update the list of connected devices for the HEADSET profile. The internal state will change
|
||||||
|
* to HEADSET_UNAVAILABLE or to HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the
|
||||||
|
* connected device if available.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
public void updateDevice() {
|
||||||
|
if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "updateDevice");
|
||||||
|
// Get connected devices for the headset profile. Returns the set of
|
||||||
|
// devices which are in state STATE_CONNECTED. The BluetoothDevice class
|
||||||
|
// is just a thin wrapper for a Bluetooth hardware address.
|
||||||
|
final List<BluetoothDevice> devices;
|
||||||
|
if (hasBluetoothConnectPermission()) {
|
||||||
|
devices = bluetoothHeadset.getConnectedDevices();
|
||||||
|
} else {
|
||||||
|
devices = ImmutableList.of();
|
||||||
|
}
|
||||||
|
if (devices.isEmpty()) {
|
||||||
|
bluetoothDevice = null;
|
||||||
|
bluetoothState = State.HEADSET_UNAVAILABLE;
|
||||||
|
Log.d(Config.LOGTAG, "No connected bluetooth headset");
|
||||||
|
} else {
|
||||||
|
// Always use first device in list. Android only supports one device.
|
||||||
|
bluetoothDevice = devices.get(0);
|
||||||
|
bluetoothState = State.HEADSET_AVAILABLE;
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"Connected bluetooth headset: "
|
||||||
|
+ "name="
|
||||||
|
+ bluetoothDevice.getName()
|
||||||
|
+ ", "
|
||||||
|
+ "state="
|
||||||
|
+ stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice))
|
||||||
|
+ ", SCO audio="
|
||||||
|
+ bluetoothHeadset.isAudioConnected(bluetoothDevice));
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "updateDevice done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stubs for test mocks. */
|
||||||
|
@Nullable
|
||||||
|
protected AudioManager getAudioManager(Context context) {
|
||||||
|
return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
|
||||||
|
apprtcContext.registerReceiver(receiver, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void unregisterReceiver(BroadcastReceiver receiver) {
|
||||||
|
apprtcContext.unregisterReceiver(receiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean getBluetoothProfileProxy(
|
||||||
|
Context context, BluetoothProfile.ServiceListener listener, int profile) {
|
||||||
|
return bluetoothAdapter.getProfileProxy(context, listener, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean hasBluetoothConnectPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
return ActivityCompat.checkSelfPermission(
|
||||||
|
apprtcContext, Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensures that the audio manager updates its list of available audio devices. */
|
||||||
|
private void updateAudioDeviceState() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(Config.LOGTAG, "updateAudioDeviceState");
|
||||||
|
apprtcAudioManager.updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */
|
||||||
|
private void startTimer() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(Config.LOGTAG, "startTimer");
|
||||||
|
handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels any outstanding timer tasks. */
|
||||||
|
private void cancelTimer() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
Log.d(Config.LOGTAG, "cancelTimer");
|
||||||
|
handler.removeCallbacks(bluetoothTimeoutRunnable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when start of the BT SCO channel takes too long time. Usually happens when the BT
|
||||||
|
* device has been turned on during an ongoing call.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private void bluetoothTimeout() {
|
||||||
|
ThreadUtils.checkIsOnMainThread();
|
||||||
|
if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"bluetoothTimeout: BT state="
|
||||||
|
+ bluetoothState
|
||||||
|
+ ", "
|
||||||
|
+ "attempts: "
|
||||||
|
+ scoConnectionAttempts
|
||||||
|
+ ", "
|
||||||
|
+ "SCO is on: "
|
||||||
|
+ isScoOn());
|
||||||
|
if (bluetoothState != State.SCO_CONNECTING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bluetooth SCO should be connecting; check the latest result.
|
||||||
|
boolean scoConnected = false;
|
||||||
|
final List<BluetoothDevice> devices;
|
||||||
|
if (hasBluetoothConnectPermission()) {
|
||||||
|
devices = bluetoothHeadset.getConnectedDevices();
|
||||||
|
} else {
|
||||||
|
devices = Collections.emptyList();
|
||||||
|
}
|
||||||
|
if (devices.size() > 0) {
|
||||||
|
bluetoothDevice = devices.get(0);
|
||||||
|
if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) {
|
||||||
|
Log.d(Config.LOGTAG, "SCO connected with " + bluetoothDevice.getName());
|
||||||
|
scoConnected = true;
|
||||||
|
} else {
|
||||||
|
Log.d(Config.LOGTAG, "SCO is not connected with " + bluetoothDevice.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scoConnected) {
|
||||||
|
// We thought BT had timed out, but it's actually on; updating state.
|
||||||
|
bluetoothState = State.SCO_CONNECTED;
|
||||||
|
scoConnectionAttempts = 0;
|
||||||
|
} else {
|
||||||
|
// Give up and "cancel" our request by calling stopBluetoothSco().
|
||||||
|
Log.w(Config.LOGTAG, "BT failed to connect after timeout");
|
||||||
|
stopScoAudio();
|
||||||
|
}
|
||||||
|
updateAudioDeviceState();
|
||||||
|
Log.d(Config.LOGTAG, "bluetoothTimeout done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether audio uses Bluetooth SCO. */
|
||||||
|
private boolean isScoOn() {
|
||||||
|
return audioManager.isBluetoothScoOn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts BluetoothAdapter states into local string representations. */
|
||||||
|
private String stateToString(int state) {
|
||||||
|
switch (state) {
|
||||||
|
case BluetoothAdapter.STATE_DISCONNECTED:
|
||||||
|
return "DISCONNECTED";
|
||||||
|
case BluetoothAdapter.STATE_CONNECTED:
|
||||||
|
return "CONNECTED";
|
||||||
|
case BluetoothAdapter.STATE_CONNECTING:
|
||||||
|
return "CONNECTING";
|
||||||
|
case BluetoothAdapter.STATE_DISCONNECTING:
|
||||||
|
return "DISCONNECTING";
|
||||||
|
case BluetoothAdapter.STATE_OFF:
|
||||||
|
return "OFF";
|
||||||
|
case BluetoothAdapter.STATE_ON:
|
||||||
|
return "ON";
|
||||||
|
case BluetoothAdapter.STATE_TURNING_OFF:
|
||||||
|
// Indicates the local Bluetooth adapter is turning off. Local clients should
|
||||||
|
// immediately
|
||||||
|
// attempt graceful disconnection of any remote links.
|
||||||
|
return "TURNING_OFF";
|
||||||
|
case BluetoothAdapter.STATE_TURNING_ON:
|
||||||
|
// Indicates the local Bluetooth adapter is turning on. However local clients should
|
||||||
|
// wait
|
||||||
|
// for STATE_ON before attempting to use the adapter.
|
||||||
|
return "TURNING_ON";
|
||||||
|
default:
|
||||||
|
return "INVALID";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bluetooth connection state.
|
||||||
|
public enum State {
|
||||||
|
// Bluetooth is not available; no adapter or Bluetooth is off.
|
||||||
|
UNINITIALIZED,
|
||||||
|
// Bluetooth error happened when trying to start Bluetooth.
|
||||||
|
ERROR,
|
||||||
|
// Bluetooth proxy object for the Headset profile exists, but no connected headset devices,
|
||||||
|
// SCO is not started or disconnected.
|
||||||
|
HEADSET_UNAVAILABLE,
|
||||||
|
// Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset
|
||||||
|
// present, but SCO is not started or disconnected.
|
||||||
|
HEADSET_AVAILABLE,
|
||||||
|
// Bluetooth audio SCO connection with remote device is closing.
|
||||||
|
SCO_DISCONNECTING,
|
||||||
|
// Bluetooth audio SCO connection with remote device is initiated.
|
||||||
|
SCO_CONNECTING,
|
||||||
|
// Bluetooth audio SCO connection with remote device is established.
|
||||||
|
SCO_CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of an interface that notifies BluetoothProfile IPC clients when they have been
|
||||||
|
* connected to or disconnected from the service.
|
||||||
|
*/
|
||||||
|
private class BluetoothServiceListener implements BluetoothProfile.ServiceListener {
|
||||||
|
@Override
|
||||||
|
// Called to notify the client when the proxy object has been connected to the service.
|
||||||
|
// Once we have the profile proxy object, we can use it to monitor the state of the
|
||||||
|
// connection and perform other operations that are relevant to the headset profile.
|
||||||
|
public void onServiceConnected(int profile, BluetoothProfile proxy) {
|
||||||
|
if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState);
|
||||||
|
// Android only supports one connected Bluetooth Headset at a time.
|
||||||
|
bluetoothHeadset = (BluetoothHeadset) proxy;
|
||||||
|
updateAudioDeviceState();
|
||||||
|
Log.d(Config.LOGTAG, "onServiceConnected done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
/** Notifies the client when the proxy object has been disconnected from the service. */
|
||||||
|
public void onServiceDisconnected(int profile) {
|
||||||
|
if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState);
|
||||||
|
stopScoAudio();
|
||||||
|
bluetoothHeadset = null;
|
||||||
|
bluetoothDevice = null;
|
||||||
|
bluetoothState = State.HEADSET_UNAVAILABLE;
|
||||||
|
updateAudioDeviceState();
|
||||||
|
Log.d(Config.LOGTAG, "onServiceDisconnected done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intent broadcast receiver which handles changes in Bluetooth device availability.
|
||||||
|
// Detects headset changes and Bluetooth SCO state changes.
|
||||||
|
private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (bluetoothState == State.UNINITIALIZED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String action = intent.getAction();
|
||||||
|
// Change in connection state of the Headset profile. Note that the
|
||||||
|
// change does not tell us anything about whether we're streaming
|
||||||
|
// audio to BT over SCO. Typically received when user turns on a BT
|
||||||
|
// headset while audio is active using another audio device.
|
||||||
|
if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
|
||||||
|
final int state =
|
||||||
|
intent.getIntExtra(
|
||||||
|
BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED);
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"BluetoothHeadsetBroadcastReceiver.onReceive: "
|
||||||
|
+ "a=ACTION_CONNECTION_STATE_CHANGED, "
|
||||||
|
+ "s="
|
||||||
|
+ stateToString(state)
|
||||||
|
+ ", "
|
||||||
|
+ "sb="
|
||||||
|
+ isInitialStickyBroadcast()
|
||||||
|
+ ", "
|
||||||
|
+ "BT state: "
|
||||||
|
+ bluetoothState);
|
||||||
|
if (state == BluetoothHeadset.STATE_CONNECTED) {
|
||||||
|
scoConnectionAttempts = 0;
|
||||||
|
updateAudioDeviceState();
|
||||||
|
} else if (state == BluetoothHeadset.STATE_CONNECTING) {
|
||||||
|
// No action needed.
|
||||||
|
} else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
|
||||||
|
// No action needed.
|
||||||
|
} else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
|
||||||
|
// Bluetooth is probably powered off during the call.
|
||||||
|
stopScoAudio();
|
||||||
|
updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
// Change in the audio (SCO) connection state of the Headset profile.
|
||||||
|
// Typically received after call to startScoAudio() has finalized.
|
||||||
|
} else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
|
||||||
|
final int state =
|
||||||
|
intent.getIntExtra(
|
||||||
|
BluetoothHeadset.EXTRA_STATE,
|
||||||
|
BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"BluetoothHeadsetBroadcastReceiver.onReceive: "
|
||||||
|
+ "a=ACTION_AUDIO_STATE_CHANGED, "
|
||||||
|
+ "s="
|
||||||
|
+ stateToString(state)
|
||||||
|
+ ", "
|
||||||
|
+ "sb="
|
||||||
|
+ isInitialStickyBroadcast()
|
||||||
|
+ ", "
|
||||||
|
+ "BT state: "
|
||||||
|
+ bluetoothState);
|
||||||
|
if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
|
||||||
|
cancelTimer();
|
||||||
|
if (bluetoothState == State.SCO_CONNECTING) {
|
||||||
|
Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connected");
|
||||||
|
bluetoothState = State.SCO_CONNECTED;
|
||||||
|
scoConnectionAttempts = 0;
|
||||||
|
updateAudioDeviceState();
|
||||||
|
} else {
|
||||||
|
Log.w(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED");
|
||||||
|
}
|
||||||
|
} else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
|
||||||
|
Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connecting...");
|
||||||
|
} else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
|
||||||
|
Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now disconnected");
|
||||||
|
if (isInitialStickyBroadcast()) {
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateAudioDeviceState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, "onReceive done: BT state=" + bluetoothState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,161 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2014 The WebRTC Project Authors. All rights reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by a BSD-style license
|
||||||
|
* that can be found in the LICENSE file in the root of the source
|
||||||
|
* tree. An additional intellectual property rights grant can be found
|
||||||
|
* in the file PATENTS. All contributing project authors may
|
||||||
|
* be found in the AUTHORS file in the root of the source tree.
|
||||||
|
*/
|
||||||
|
package eu.siacs.conversations.services;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.hardware.Sensor;
|
||||||
|
import android.hardware.SensorEvent;
|
||||||
|
import android.hardware.SensorEventListener;
|
||||||
|
import android.hardware.SensorManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.utils.AppRTCUtils;
|
||||||
|
import org.webrtc.ThreadUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppRTCProximitySensor manages functions related to the proximity sensor in the AppRTC demo. On
|
||||||
|
* most device, the proximity sensor is implemented as a boolean-sensor. It returns just two values
|
||||||
|
* "NEAR" or "FAR". Thresholding is done on the LUX value i.e. the LUX value of the light sensor is
|
||||||
|
* compared with a threshold. A LUX-value more than the threshold means the proximity sensor returns
|
||||||
|
* "FAR". Anything less than the threshold value and the sensor returns "NEAR".
|
||||||
|
*/
|
||||||
|
public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
|
// This class should be created, started and stopped on one thread
|
||||||
|
// (e.g. the main thread). We use |nonThreadSafe| to ensure that this is
|
||||||
|
// the case. Only active when |DEBUG| is set to true.
|
||||||
|
private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker();
|
||||||
|
private final Runnable onSensorStateListener;
|
||||||
|
private final SensorManager sensorManager;
|
||||||
|
@Nullable private Sensor proximitySensor;
|
||||||
|
private boolean lastStateReportIsNear;
|
||||||
|
|
||||||
|
private AppRTCProximitySensor(Context context, Runnable sensorStateListener) {
|
||||||
|
Log.d(Config.LOGTAG, "AppRTCProximitySensor" + AppRTCUtils.getThreadInfo());
|
||||||
|
onSensorStateListener = sensorStateListener;
|
||||||
|
sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construction */
|
||||||
|
static AppRTCProximitySensor create(Context context, Runnable sensorStateListener) {
|
||||||
|
return new AppRTCProximitySensor(context, sensorStateListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activate the proximity sensor. Also do initialization if called for the first time. */
|
||||||
|
public boolean start() {
|
||||||
|
threadChecker.checkIsOnValidThread();
|
||||||
|
Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo());
|
||||||
|
if (!initDefaultSensor()) {
|
||||||
|
// Proximity sensor is not supported on this device.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deactivate the proximity sensor. */
|
||||||
|
public void stop() {
|
||||||
|
threadChecker.checkIsOnValidThread();
|
||||||
|
Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo());
|
||||||
|
if (proximitySensor == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sensorManager.unregisterListener(this, proximitySensor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Getter for last reported state. Set to true if "near" is reported. */
|
||||||
|
public boolean sensorReportsNearState() {
|
||||||
|
threadChecker.checkIsOnValidThread();
|
||||||
|
return lastStateReportIsNear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||||
|
threadChecker.checkIsOnValidThread();
|
||||||
|
AppRTCUtils.assertIsTrue(sensor.getType() == Sensor.TYPE_PROXIMITY);
|
||||||
|
if (accuracy == SensorManager.SENSOR_STATUS_UNRELIABLE) {
|
||||||
|
Log.e(Config.LOGTAG, "The values returned by this sensor cannot be trusted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final void onSensorChanged(SensorEvent event) {
|
||||||
|
threadChecker.checkIsOnValidThread();
|
||||||
|
AppRTCUtils.assertIsTrue(event.sensor.getType() == Sensor.TYPE_PROXIMITY);
|
||||||
|
// As a best practice; do as little as possible within this method and
|
||||||
|
// avoid blocking.
|
||||||
|
float distanceInCentimeters = event.values[0];
|
||||||
|
if (distanceInCentimeters < proximitySensor.getMaximumRange()) {
|
||||||
|
Log.d(Config.LOGTAG, "Proximity sensor => NEAR state");
|
||||||
|
lastStateReportIsNear = true;
|
||||||
|
} else {
|
||||||
|
Log.d(Config.LOGTAG, "Proximity sensor => FAR state");
|
||||||
|
lastStateReportIsNear = false;
|
||||||
|
}
|
||||||
|
// Report about new state to listening client. Client can then call
|
||||||
|
// sensorReportsNearState() to query the current state (NEAR or FAR).
|
||||||
|
if (onSensorStateListener != null) {
|
||||||
|
onSensorStateListener.run();
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"onSensorChanged"
|
||||||
|
+ AppRTCUtils.getThreadInfo()
|
||||||
|
+ ": "
|
||||||
|
+ "accuracy="
|
||||||
|
+ event.accuracy
|
||||||
|
+ ", timestamp="
|
||||||
|
+ event.timestamp
|
||||||
|
+ ", distance="
|
||||||
|
+ event.values[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7) does not support
|
||||||
|
* this type of sensor and false will be returned in such cases.
|
||||||
|
*/
|
||||||
|
private boolean initDefaultSensor() {
|
||||||
|
if (proximitySensor != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
|
||||||
|
if (proximitySensor == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
logProximitySensorInfo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for logging information about the proximity sensor. */
|
||||||
|
private void logProximitySensorInfo() {
|
||||||
|
if (proximitySensor == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StringBuilder info = new StringBuilder("Proximity sensor: ");
|
||||||
|
info.append("name=").append(proximitySensor.getName());
|
||||||
|
info.append(", vendor: ").append(proximitySensor.getVendor());
|
||||||
|
info.append(", power: ").append(proximitySensor.getPower());
|
||||||
|
info.append(", resolution: ").append(proximitySensor.getResolution());
|
||||||
|
info.append(", max range: ").append(proximitySensor.getMaximumRange());
|
||||||
|
info.append(", min delay: ").append(proximitySensor.getMinDelay());
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
|
||||||
|
// Added in API level 20.
|
||||||
|
info.append(", type: ").append(proximitySensor.getStringType());
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
// Added in API level 21.
|
||||||
|
info.append(", max delay: ").append(proximitySensor.getMaxDelay());
|
||||||
|
info.append(", reporting mode: ").append(proximitySensor.getReportingMode());
|
||||||
|
info.append(", isWakeUpSensor: ").append(proximitySensor.isWakeUpSensor());
|
||||||
|
}
|
||||||
|
Log.d(Config.LOGTAG, info.toString());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2014 The WebRTC Project Authors. All rights reserved.
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by a BSD-style license
|
||||||
|
* that can be found in the LICENSE file in the root of the source
|
||||||
|
* tree. An additional intellectual property rights grant can be found
|
||||||
|
* in the file PATENTS. All contributing project authors may
|
||||||
|
* be found in the AUTHORS file in the root of the source tree.
|
||||||
|
*/
|
||||||
|
package eu.siacs.conversations.utils;
|
||||||
|
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
/** AppRTCUtils provides helper functions for managing thread safety. */
|
||||||
|
public final class AppRTCUtils {
|
||||||
|
private AppRTCUtils() {}
|
||||||
|
|
||||||
|
/** Helper method which throws an exception when an assertion has failed. */
|
||||||
|
public static void assertIsTrue(boolean condition) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new AssertionError("Expected condition to be true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper method for building a string of thread information. */
|
||||||
|
public static String getThreadInfo() {
|
||||||
|
return "@[name="
|
||||||
|
+ Thread.currentThread().getName()
|
||||||
|
+ ", id="
|
||||||
|
+ Thread.currentThread().getId()
|
||||||
|
+ "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Information about the current build, taken from system properties. */
|
||||||
|
public static void logDeviceInfo(String tag) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Android SDK: "
|
||||||
|
+ Build.VERSION.SDK_INT
|
||||||
|
+ ", "
|
||||||
|
+ "Release: "
|
||||||
|
+ Build.VERSION.RELEASE
|
||||||
|
+ ", "
|
||||||
|
+ "Brand: "
|
||||||
|
+ Build.BRAND
|
||||||
|
+ ", "
|
||||||
|
+ "Device: "
|
||||||
|
+ Build.DEVICE
|
||||||
|
+ ", "
|
||||||
|
+ "Id: "
|
||||||
|
+ Build.ID
|
||||||
|
+ ", "
|
||||||
|
+ "Hardware: "
|
||||||
|
+ Build.HARDWARE
|
||||||
|
+ ", "
|
||||||
|
+ "Manufacturer: "
|
||||||
|
+ Build.MANUFACTURER
|
||||||
|
+ ", "
|
||||||
|
+ "Model: "
|
||||||
|
+ Build.MODEL
|
||||||
|
+ ", "
|
||||||
|
+ "Product: "
|
||||||
|
+ Build.PRODUCT);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.google.common.base.Objects;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
|
||||||
|
import im.conversations.android.IDs;
|
||||||
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
import im.conversations.android.xmpp.model.stanza.Iq;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
|
||||||
|
public abstract class AbstractJingleConnection extends XmppConnection.Delegate {
|
||||||
|
|
||||||
|
public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-";
|
||||||
|
public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-";
|
||||||
|
|
||||||
|
protected final Id id;
|
||||||
|
private final Jid initiator;
|
||||||
|
|
||||||
|
AbstractJingleConnection(
|
||||||
|
final Context context,
|
||||||
|
final XmppConnection connection,
|
||||||
|
final Id id,
|
||||||
|
final Jid initiator) {
|
||||||
|
super(context, connection);
|
||||||
|
this.id = id;
|
||||||
|
this.initiator = initiator;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isInitiator() {
|
||||||
|
return initiator.equals(connection.getBoundAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void deliverPacket(Iq jinglePacket);
|
||||||
|
|
||||||
|
public Id getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void notifyRebound();
|
||||||
|
|
||||||
|
public static class Id implements OngoingRtpSession {
|
||||||
|
public final Jid with;
|
||||||
|
public final String sessionId;
|
||||||
|
|
||||||
|
private Id(final Jid with, final String sessionId) {
|
||||||
|
Preconditions.checkNotNull(with);
|
||||||
|
Preconditions.checkNotNull(sessionId);
|
||||||
|
this.with = with;
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Id of(final JinglePacket jinglePacket) {
|
||||||
|
return new Id(jinglePacket.getFrom(), jinglePacket.getSessionId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Id of(Jid with, final String sessionId) {
|
||||||
|
return new Id(with, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Id of(Jid with) {
|
||||||
|
return new Id(with, IDs.medium());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Jid getWith() {
|
||||||
|
return with;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getSessionId() {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
Id id = (Id) o;
|
||||||
|
return Objects.equal(with, id.with) && Objects.equal(sessionId, id.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(with, sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum State {
|
||||||
|
NULL, // default value; nothing has been sent or received yet
|
||||||
|
PROPOSED,
|
||||||
|
ACCEPTED,
|
||||||
|
PROCEED,
|
||||||
|
REJECTED,
|
||||||
|
REJECTED_RACED, // used when we want to reject but haven’t received session init yet
|
||||||
|
RETRACTED,
|
||||||
|
RETRACTED_RACED, // used when receiving a retract after we already asked to proceed
|
||||||
|
SESSION_INITIALIZED, // equal to 'PENDING'
|
||||||
|
SESSION_INITIALIZED_PRE_APPROVED,
|
||||||
|
SESSION_ACCEPTED, // equal to 'ACTIVE'
|
||||||
|
TERMINATED_SUCCESS, // equal to 'ENDED' (after successful call) ui will just close
|
||||||
|
TERMINATED_DECLINED_OR_BUSY, // equal to 'ENDED' (after other party declined the call)
|
||||||
|
TERMINATED_CONNECTIVITY_ERROR, // equal to 'ENDED' (but after network failures; ui will
|
||||||
|
// display retry button)
|
||||||
|
TERMINATED_CANCEL_OR_TIMEOUT, // more or less the same as retracted; caller pressed end call
|
||||||
|
// before session was accepted
|
||||||
|
TERMINATED_APPLICATION_FAILURE,
|
||||||
|
TERMINATED_SECURITY_ERROR
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
import com.google.common.base.Objects;
|
||||||
|
import com.google.common.collect.Collections2;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class ContentAddition {
|
||||||
|
|
||||||
|
public final Direction direction;
|
||||||
|
public final Set<Summary> summary;
|
||||||
|
|
||||||
|
private ContentAddition(Direction direction, Set<Summary> summary) {
|
||||||
|
this.direction = direction;
|
||||||
|
this.summary = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Media> media() {
|
||||||
|
return ImmutableSet.copyOf(Collections2.transform(summary, s -> s.media));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ContentAddition of(final Direction direction, final RtpContentMap rtpContentMap) {
|
||||||
|
return new ContentAddition(direction, summary(rtpContentMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<Summary> summary(final RtpContentMap rtpContentMap) {
|
||||||
|
return ImmutableSet.copyOf(
|
||||||
|
Collections2.transform(
|
||||||
|
rtpContentMap.contents.entrySet(),
|
||||||
|
e -> {
|
||||||
|
final RtpContentMap.DescriptionTransport dt = e.getValue();
|
||||||
|
return new Summary(e.getKey(), dt.description.getMedia(), dt.senders);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("direction", direction)
|
||||||
|
.add("summary", summary)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Direction {
|
||||||
|
OUTGOING,
|
||||||
|
INCOMING
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Summary {
|
||||||
|
public final String name;
|
||||||
|
public final Media media;
|
||||||
|
public final Content.Senders senders;
|
||||||
|
|
||||||
|
private Summary(final String name, final Media media, final Content.Senders senders) {
|
||||||
|
this.name = name;
|
||||||
|
this.media = media;
|
||||||
|
this.senders = senders;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
Summary summary = (Summary) o;
|
||||||
|
return Objects.equal(name, summary.name)
|
||||||
|
&& media == summary.media
|
||||||
|
&& senders == summary.senders;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(name, media, senders);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("name", name)
|
||||||
|
.add("media", media)
|
||||||
|
.add("senders", senders)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import java.net.Inet6Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.NetworkInterface;
|
||||||
|
import java.net.SocketException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
|
||||||
|
public class DirectConnectionUtils {
|
||||||
|
|
||||||
|
private static List<InetAddress> getLocalAddresses() {
|
||||||
|
final List<InetAddress> addresses = new ArrayList<>();
|
||||||
|
final Enumeration<NetworkInterface> interfaces;
|
||||||
|
try {
|
||||||
|
interfaces = NetworkInterface.getNetworkInterfaces();
|
||||||
|
} catch (SocketException e) {
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
while (interfaces.hasMoreElements()) {
|
||||||
|
NetworkInterface networkInterface = interfaces.nextElement();
|
||||||
|
final Enumeration<InetAddress> inetAddressEnumeration =
|
||||||
|
networkInterface.getInetAddresses();
|
||||||
|
while (inetAddressEnumeration.hasMoreElements()) {
|
||||||
|
final InetAddress inetAddress = inetAddressEnumeration.nextElement();
|
||||||
|
if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inetAddress instanceof Inet6Address) {
|
||||||
|
// let's get rid of scope
|
||||||
|
try {
|
||||||
|
addresses.add(Inet6Address.getByAddress(inetAddress.getAddress()));
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addresses.add(inetAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<JingleCandidate> getLocalCandidates(Jid jid) {
|
||||||
|
SecureRandom random = new SecureRandom();
|
||||||
|
ArrayList<JingleCandidate> candidates = new ArrayList<>();
|
||||||
|
for (InetAddress inetAddress : getLocalAddresses()) {
|
||||||
|
final JingleCandidate candidate =
|
||||||
|
new JingleCandidate(UUID.randomUUID().toString(), true);
|
||||||
|
candidate.setHost(inetAddress.getHostAddress());
|
||||||
|
candidate.setPort(random.nextInt(60000) + 1024);
|
||||||
|
candidate.setType(JingleCandidate.TYPE_DIRECT);
|
||||||
|
candidate.setJid(jid);
|
||||||
|
candidate.setPriority(8257536 + candidates.size());
|
||||||
|
candidates.add(candidate);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,153 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
|
||||||
|
public class JingleCandidate {
|
||||||
|
|
||||||
|
public static int TYPE_UNKNOWN;
|
||||||
|
public static int TYPE_DIRECT = 0;
|
||||||
|
public static int TYPE_PROXY = 1;
|
||||||
|
|
||||||
|
private final boolean ours;
|
||||||
|
private boolean usedByCounterpart = false;
|
||||||
|
private final String cid;
|
||||||
|
private String host;
|
||||||
|
private int port;
|
||||||
|
private int type;
|
||||||
|
private Jid jid;
|
||||||
|
private int priority;
|
||||||
|
|
||||||
|
public JingleCandidate(String cid, boolean ours) {
|
||||||
|
this.ours = ours;
|
||||||
|
this.cid = cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCid() {
|
||||||
|
return cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHost(String host) {
|
||||||
|
this.host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHost() {
|
||||||
|
return this.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJid(final Jid jid) {
|
||||||
|
this.jid = jid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Jid getJid() {
|
||||||
|
return this.jid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPort(int port) {
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPort() {
|
||||||
|
return this.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(int type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
if (type == null) {
|
||||||
|
this.type = TYPE_UNKNOWN;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case "proxy":
|
||||||
|
this.type = TYPE_PROXY;
|
||||||
|
break;
|
||||||
|
case "direct":
|
||||||
|
this.type = TYPE_DIRECT;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.type = TYPE_UNKNOWN;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPriority(int i) {
|
||||||
|
this.priority = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPriority() {
|
||||||
|
return this.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean equals(JingleCandidate other) {
|
||||||
|
return this.getCid().equals(other.getCid());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean equalValues(JingleCandidate other) {
|
||||||
|
return other != null
|
||||||
|
&& other.getHost().equals(this.getHost())
|
||||||
|
&& (other.getPort() == this.getPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOurs() {
|
||||||
|
return ours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getType() {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<JingleCandidate> parse(final List<Element> elements) {
|
||||||
|
final List<JingleCandidate> candidates = new ArrayList<>();
|
||||||
|
for (final Element element : elements) {
|
||||||
|
if ("candidate".equals(element.getName())) {
|
||||||
|
candidates.add(JingleCandidate.parse(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JingleCandidate parse(Element element) {
|
||||||
|
final JingleCandidate candidate = new JingleCandidate(element.getAttribute("cid"), false);
|
||||||
|
candidate.setHost(element.getAttribute("host"));
|
||||||
|
candidate.setJid(element.getAttributeAsJid("jid"));
|
||||||
|
candidate.setType(element.getAttribute("type"));
|
||||||
|
candidate.setPriority(Integer.parseInt(element.getAttribute("priority")));
|
||||||
|
candidate.setPort(Integer.parseInt(element.getAttribute("port")));
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Element toElement() {
|
||||||
|
Element element = new Element("candidate");
|
||||||
|
element.setAttribute("cid", this.getCid());
|
||||||
|
element.setAttribute("host", this.getHost());
|
||||||
|
element.setAttribute("port", Integer.toString(this.getPort()));
|
||||||
|
if (jid != null) {
|
||||||
|
element.setAttribute("jid", jid);
|
||||||
|
}
|
||||||
|
element.setAttribute("priority", Integer.toString(this.getPriority()));
|
||||||
|
if (this.getType() == TYPE_DIRECT) {
|
||||||
|
element.setAttribute("type", "direct");
|
||||||
|
} else if (this.getType() == TYPE_PROXY) {
|
||||||
|
element.setAttribute("type", "proxy");
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void flagAsUsedByCounterpart() {
|
||||||
|
this.usedByCounterpart = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUsedByCounterpart() {
|
||||||
|
return this.usedByCounterpart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return String.format(
|
||||||
|
"%s:%s (priority=%s,ours=%s)", getHost(), getPort(), getPriority(), isOurs());
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,34 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
|
public enum Media {
|
||||||
|
VIDEO,
|
||||||
|
AUDIO,
|
||||||
|
UNKNOWN;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nonnull
|
||||||
|
public String toString() {
|
||||||
|
return super.toString().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Media of(String value) {
|
||||||
|
try {
|
||||||
|
return value == null ? UNKNOWN : Media.valueOf(value.toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean audioOnly(Set<Media> media) {
|
||||||
|
return ImmutableSet.of(AUDIO).equals(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean videoOnly(Set<Media> media) {
|
||||||
|
return ImmutableSet.of(VIDEO).equals(media);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class MediaBuilder {
|
||||||
|
private String media;
|
||||||
|
private int port;
|
||||||
|
private String protocol;
|
||||||
|
private List<Integer> formats;
|
||||||
|
private String connectionData;
|
||||||
|
private ArrayListMultimap<String, String> attributes;
|
||||||
|
|
||||||
|
public MediaBuilder setMedia(String media) {
|
||||||
|
this.media = media;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MediaBuilder setPort(int port) {
|
||||||
|
this.port = port;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MediaBuilder setProtocol(String protocol) {
|
||||||
|
this.protocol = protocol;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MediaBuilder setFormats(List<Integer> formats) {
|
||||||
|
this.formats = formats;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MediaBuilder setConnectionData(String connectionData) {
|
||||||
|
this.connectionData = connectionData;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MediaBuilder setAttributes(ArrayListMultimap<String, String> attributes) {
|
||||||
|
this.attributes = attributes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionDescription.Media createMedia() {
|
||||||
|
return new SessionDescription.Media(
|
||||||
|
media, port, protocol, formats, connectionData, attributes);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import im.conversations.android.axolotl.AxolotlService;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
|
|
||||||
|
public class OmemoVerification {
|
||||||
|
|
||||||
|
private final AtomicBoolean deviceIdWritten = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean identityKeyWritten = new AtomicBoolean(false);
|
||||||
|
private Integer deviceId;
|
||||||
|
private IdentityKey identityKey;
|
||||||
|
|
||||||
|
public void setDeviceId(final Integer id) {
|
||||||
|
if (deviceIdWritten.compareAndSet(false, true)) {
|
||||||
|
this.deviceId = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Device Id has already been set");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDeviceId() {
|
||||||
|
Preconditions.checkNotNull(this.deviceId, "Device ID is null");
|
||||||
|
return this.deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasDeviceId() {
|
||||||
|
return this.deviceId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionFingerprint(final IdentityKey identityKey) {
|
||||||
|
Preconditions.checkNotNull(identityKey, "IdentityKey must not be null");
|
||||||
|
if (identityKeyWritten.compareAndSet(false, true)) {
|
||||||
|
this.identityKey = identityKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Identity Key has already been set");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IdentityKey getFingerprint() {
|
||||||
|
return this.identityKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrEnsureEqual(AxolotlService.OmemoVerifiedPayload<?> omemoVerifiedPayload) {
|
||||||
|
setOrEnsureEqual(omemoVerifiedPayload.getDeviceId(), omemoVerifiedPayload.getFingerprint());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrEnsureEqual(final int deviceId, final IdentityKey identityKey) {
|
||||||
|
Preconditions.checkNotNull(identityKey, "IdentityKey must not be null");
|
||||||
|
if (this.deviceIdWritten.get() || this.identityKeyWritten.get()) {
|
||||||
|
if (this.identityKey == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No session fingerprint has been previously provided");
|
||||||
|
}
|
||||||
|
if (!identityKey.equals(this.identityKey)) {
|
||||||
|
throw new SecurityException("IdentityKeys did not match");
|
||||||
|
}
|
||||||
|
if (this.deviceId == null) {
|
||||||
|
throw new IllegalStateException("No Device Id has been previously provided");
|
||||||
|
}
|
||||||
|
if (this.deviceId != deviceId) {
|
||||||
|
throw new IllegalStateException("Device Ids did not match");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setSessionFingerprint(identityKey);
|
||||||
|
this.setDeviceId(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasFingerprint() {
|
||||||
|
return this.identityKey != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("deviceId", deviceId)
|
||||||
|
.add("fingerprint", identityKey)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class OmemoVerifiedRtpContentMap extends RtpContentMap {
|
||||||
|
public OmemoVerifiedRtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
|
||||||
|
super(group, contents);
|
||||||
|
for (final DescriptionTransport descriptionTransport : contents.values()) {
|
||||||
|
if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
|
||||||
|
((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport)
|
||||||
|
.ensureNoPlaintextFingerprint();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"OmemoVerifiedRtpContentMap contains non-verified transport info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
public interface OnPrimaryCandidateFound {
|
||||||
|
void onPrimaryCandidateFound(boolean success, JingleCandidate canditate);
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
public interface OnTransportConnected {
|
||||||
|
void failed();
|
||||||
|
|
||||||
|
void established();
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
|
||||||
|
public interface OngoingRtpSession {
|
||||||
|
Jid getWith();
|
||||||
|
|
||||||
|
String getSessionId();
|
||||||
|
}
|
|
@ -0,0 +1,489 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
import com.google.common.base.Objects;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.base.Predicates;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.Collections2;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import com.google.common.collect.Maps;
|
||||||
|
import com.google.common.collect.Sets;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
|
public class RtpContentMap {
|
||||||
|
|
||||||
|
public final Group group;
|
||||||
|
public final Map<String, DescriptionTransport> contents;
|
||||||
|
|
||||||
|
public RtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
|
||||||
|
this.group = group;
|
||||||
|
this.contents = contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpContentMap of(final JinglePacket jinglePacket) {
|
||||||
|
final Map<String, DescriptionTransport> contents =
|
||||||
|
DescriptionTransport.of(jinglePacket.getJingleContents());
|
||||||
|
if (isOmemoVerified(contents)) {
|
||||||
|
return new OmemoVerifiedRtpContentMap(jinglePacket.getGroup(), contents);
|
||||||
|
} else {
|
||||||
|
return new RtpContentMap(jinglePacket.getGroup(), contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isOmemoVerified(Map<String, DescriptionTransport> contents) {
|
||||||
|
final Collection<DescriptionTransport> values = contents.values();
|
||||||
|
if (values.size() == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final DescriptionTransport descriptionTransport : values) {
|
||||||
|
if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpContentMap of(
|
||||||
|
final SessionDescription sessionDescription, final boolean isInitiator) {
|
||||||
|
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
|
||||||
|
new ImmutableMap.Builder<>();
|
||||||
|
for (SessionDescription.Media media : sessionDescription.media) {
|
||||||
|
final String id = Iterables.getFirst(media.attributes.get("mid"), null);
|
||||||
|
Preconditions.checkNotNull(id, "media has no mid");
|
||||||
|
contentMapBuilder.put(
|
||||||
|
id, DescriptionTransport.of(sessionDescription, isInitiator, media));
|
||||||
|
}
|
||||||
|
final String groupAttribute =
|
||||||
|
Iterables.getFirst(sessionDescription.attributes.get("group"), null);
|
||||||
|
final Group group = groupAttribute == null ? null : Group.ofSdpString(groupAttribute);
|
||||||
|
return new RtpContentMap(group, contentMapBuilder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Media> getMedia() {
|
||||||
|
return Sets.newHashSet(
|
||||||
|
Collections2.transform(
|
||||||
|
contents.values(),
|
||||||
|
input -> {
|
||||||
|
final RtpDescription rtpDescription =
|
||||||
|
input == null ? null : input.description;
|
||||||
|
return rtpDescription == null
|
||||||
|
? Media.UNKNOWN
|
||||||
|
: input.description.getMedia();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Content.Senders> getSenders() {
|
||||||
|
return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getNames() {
|
||||||
|
return ImmutableList.copyOf(contents.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
void requireContentDescriptions() {
|
||||||
|
if (this.contents.size() == 0) {
|
||||||
|
throw new IllegalStateException("No contents available");
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
|
||||||
|
if (entry.getValue().description == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
String.format("%s is lacking content description", entry.getKey()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void requireDTLSFingerprint() {
|
||||||
|
requireDTLSFingerprint(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void requireDTLSFingerprint(final boolean requireActPass) {
|
||||||
|
if (this.contents.size() == 0) {
|
||||||
|
throw new IllegalStateException("No contents available");
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
|
||||||
|
final IceUdpTransportInfo transport = entry.getValue().transport;
|
||||||
|
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
|
||||||
|
if (fingerprint == null
|
||||||
|
|| Strings.isNullOrEmpty(fingerprint.getContent())
|
||||||
|
|| Strings.isNullOrEmpty(fingerprint.getHash())) {
|
||||||
|
throw new SecurityException(
|
||||||
|
String.format(
|
||||||
|
"Use of DTLS-SRTP (XEP-0320) is required for content %s",
|
||||||
|
entry.getKey()));
|
||||||
|
}
|
||||||
|
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
|
||||||
|
if (setup == null) {
|
||||||
|
throw new SecurityException(
|
||||||
|
String.format(
|
||||||
|
"Use of DTLS-SRTP (XEP-0320) is required for content %s but"
|
||||||
|
+ " missing setup attribute",
|
||||||
|
entry.getKey()));
|
||||||
|
}
|
||||||
|
if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
|
||||||
|
throw new SecurityException(
|
||||||
|
"Initiator needs to offer ACTPASS as setup for DTLS-SRTP (XEP-0320)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) {
|
||||||
|
final JinglePacket jinglePacket = new JinglePacket(action, sessionId);
|
||||||
|
if (this.group != null) {
|
||||||
|
jinglePacket.addGroup(this.group);
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
|
||||||
|
final DescriptionTransport descriptionTransport = entry.getValue();
|
||||||
|
final Content content =
|
||||||
|
new Content(
|
||||||
|
Content.Creator.INITIATOR,
|
||||||
|
descriptionTransport.senders,
|
||||||
|
entry.getKey());
|
||||||
|
if (descriptionTransport.description != null) {
|
||||||
|
content.addChild(descriptionTransport.description);
|
||||||
|
}
|
||||||
|
content.addChild(descriptionTransport.transport);
|
||||||
|
jinglePacket.addJingleContent(content);
|
||||||
|
}
|
||||||
|
return jinglePacket;
|
||||||
|
}
|
||||||
|
|
||||||
|
RtpContentMap transportInfo(
|
||||||
|
final String contentName, final IceUdpTransportInfo.Candidate candidate) {
|
||||||
|
final RtpContentMap.DescriptionTransport descriptionTransport = contents.get(contentName);
|
||||||
|
final IceUdpTransportInfo transportInfo =
|
||||||
|
descriptionTransport == null ? null : descriptionTransport.transport;
|
||||||
|
if (transportInfo == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unable to find transport info for content name " + contentName);
|
||||||
|
}
|
||||||
|
final IceUdpTransportInfo newTransportInfo = transportInfo.cloneWrapper();
|
||||||
|
newTransportInfo.addChild(candidate);
|
||||||
|
return new RtpContentMap(
|
||||||
|
null,
|
||||||
|
ImmutableMap.of(
|
||||||
|
contentName,
|
||||||
|
new DescriptionTransport(
|
||||||
|
descriptionTransport.senders, null, newTransportInfo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
RtpContentMap transportInfo() {
|
||||||
|
return new RtpContentMap(
|
||||||
|
null,
|
||||||
|
Maps.transformValues(
|
||||||
|
contents,
|
||||||
|
dt ->
|
||||||
|
new DescriptionTransport(
|
||||||
|
dt.senders, null, dt.transport.cloneWrapper())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IceUdpTransportInfo.Credentials getDistinctCredentials() {
|
||||||
|
final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
|
||||||
|
final IceUdpTransportInfo.Credentials credentials =
|
||||||
|
Iterables.getFirst(allCredentials, null);
|
||||||
|
if (allCredentials.size() == 1 && credentials != null) {
|
||||||
|
if (Strings.isNullOrEmpty(credentials.password)
|
||||||
|
|| Strings.isNullOrEmpty(credentials.ufrag)) {
|
||||||
|
throw new IllegalStateException("Credentials are missing password or ufrag");
|
||||||
|
}
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Content map does not have distinct credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<IceUdpTransportInfo.Credentials> getCredentials() {
|
||||||
|
final Set<IceUdpTransportInfo.Credentials> credentials =
|
||||||
|
ImmutableSet.copyOf(
|
||||||
|
Collections2.transform(
|
||||||
|
contents.values(), dt -> dt.transport.getCredentials()));
|
||||||
|
if (credentials.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Content map does not have any credentials");
|
||||||
|
}
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IceUdpTransportInfo.Credentials getCredentials(final String contentName) {
|
||||||
|
final DescriptionTransport descriptionTransport = this.contents.get(contentName);
|
||||||
|
if (descriptionTransport == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
String.format(
|
||||||
|
"Unable to find transport info for content name %s", contentName));
|
||||||
|
}
|
||||||
|
return descriptionTransport.transport.getCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IceUdpTransportInfo.Setup getDtlsSetup() {
|
||||||
|
final Set<IceUdpTransportInfo.Setup> setups =
|
||||||
|
ImmutableSet.copyOf(
|
||||||
|
Collections2.transform(
|
||||||
|
contents.values(), dt -> dt.transport.getFingerprint().getSetup()));
|
||||||
|
final IceUdpTransportInfo.Setup setup = Iterables.getFirst(setups, null);
|
||||||
|
if (setups.size() == 1 && setup != null) {
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
private DTLS getDistinctDtls() {
|
||||||
|
final Set<DTLS> dtlsSet =
|
||||||
|
ImmutableSet.copyOf(
|
||||||
|
Collections2.transform(
|
||||||
|
contents.values(),
|
||||||
|
dt -> {
|
||||||
|
final IceUdpTransportInfo.Fingerprint fp =
|
||||||
|
dt.transport.getFingerprint();
|
||||||
|
return new DTLS(fp.getHash(), fp.getSetup(), fp.getContent());
|
||||||
|
}));
|
||||||
|
final DTLS dtls = Iterables.getFirst(dtlsSet, null);
|
||||||
|
if (dtlsSet.size() == 1 && dtls != null) {
|
||||||
|
return dtls;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean emptyCandidates() {
|
||||||
|
int count = 0;
|
||||||
|
for (DescriptionTransport descriptionTransport : contents.values()) {
|
||||||
|
count += descriptionTransport.transport.getCandidates().size();
|
||||||
|
}
|
||||||
|
return count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpContentMap modifiedCredentials(
|
||||||
|
IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
|
||||||
|
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
|
||||||
|
new ImmutableMap.Builder<>();
|
||||||
|
for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
|
||||||
|
final DescriptionTransport descriptionTransport = content.getValue();
|
||||||
|
final RtpDescription rtpDescription = descriptionTransport.description;
|
||||||
|
final IceUdpTransportInfo transportInfo = descriptionTransport.transport;
|
||||||
|
final IceUdpTransportInfo modifiedTransportInfo =
|
||||||
|
transportInfo.modifyCredentials(credentials, setup);
|
||||||
|
contentMapBuilder.put(
|
||||||
|
content.getKey(),
|
||||||
|
new DescriptionTransport(
|
||||||
|
descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
|
||||||
|
}
|
||||||
|
return new RtpContentMap(this.group, contentMapBuilder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpContentMap modifiedSenders(final Content.Senders senders) {
|
||||||
|
return new RtpContentMap(
|
||||||
|
this.group,
|
||||||
|
Maps.transformValues(
|
||||||
|
contents,
|
||||||
|
dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpContentMap toContentModification(final Collection<String> modifications) {
|
||||||
|
return new RtpContentMap(
|
||||||
|
this.group,
|
||||||
|
Maps.transformValues(
|
||||||
|
Maps.filterKeys(contents, Predicates.in(modifications)),
|
||||||
|
dt ->
|
||||||
|
new DescriptionTransport(
|
||||||
|
dt.senders, dt.description, IceUdpTransportInfo.STUB)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpContentMap toStub() {
|
||||||
|
return new RtpContentMap(
|
||||||
|
null,
|
||||||
|
Maps.transformValues(
|
||||||
|
this.contents,
|
||||||
|
dt ->
|
||||||
|
new DescriptionTransport(
|
||||||
|
dt.senders,
|
||||||
|
RtpDescription.stub(dt.description.getMedia()),
|
||||||
|
IceUdpTransportInfo.STUB)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpContentMap activeContents() {
|
||||||
|
return new RtpContentMap(
|
||||||
|
group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Diff diff(final RtpContentMap rtpContentMap) {
|
||||||
|
final Set<String> existingContentIds = this.contents.keySet();
|
||||||
|
final Set<String> newContentIds = rtpContentMap.contents.keySet();
|
||||||
|
return new Diff(
|
||||||
|
ImmutableSet.copyOf(Sets.difference(newContentIds, existingContentIds)),
|
||||||
|
ImmutableSet.copyOf(Sets.difference(existingContentIds, newContentIds)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean iceRestart(final RtpContentMap rtpContentMap) {
|
||||||
|
try {
|
||||||
|
return !getDistinctCredentials().equals(rtpContentMap.getDistinctCredentials());
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpContentMap addContent(
|
||||||
|
final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) {
|
||||||
|
final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials();
|
||||||
|
final DTLS dtls = getDistinctDtls();
|
||||||
|
final IceUdpTransportInfo iceUdpTransportInfo =
|
||||||
|
IceUdpTransportInfo.of(credentials, setup, dtls.hash, dtls.fingerprint);
|
||||||
|
final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
|
||||||
|
/*new ImmutableMap.Builder<String, DescriptionTransport>()
|
||||||
|
.putAll(contents)
|
||||||
|
.putAll(modification.contents)
|
||||||
|
.build();*/
|
||||||
|
final Map<String, DescriptionTransport> combinedFixedTransport =
|
||||||
|
Maps.transformValues(
|
||||||
|
combined,
|
||||||
|
dt ->
|
||||||
|
new DescriptionTransport(
|
||||||
|
dt.senders, dt.description, iceUdpTransportInfo));
|
||||||
|
return new RtpContentMap(modification.group, combinedFixedTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, DescriptionTransport> merge(
|
||||||
|
final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
|
||||||
|
final Map<String, DescriptionTransport> combined = new HashMap<>();
|
||||||
|
combined.putAll(a);
|
||||||
|
combined.putAll(b);
|
||||||
|
return ImmutableMap.copyOf(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DescriptionTransport {
|
||||||
|
public final Content.Senders senders;
|
||||||
|
public final RtpDescription description;
|
||||||
|
public final IceUdpTransportInfo transport;
|
||||||
|
|
||||||
|
public DescriptionTransport(
|
||||||
|
final Content.Senders senders,
|
||||||
|
final RtpDescription description,
|
||||||
|
final IceUdpTransportInfo transport) {
|
||||||
|
this.senders = senders;
|
||||||
|
this.description = description;
|
||||||
|
this.transport = transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DescriptionTransport of(final Content content) {
|
||||||
|
final GenericDescription description = content.getDescription();
|
||||||
|
final GenericTransportInfo transportInfo = content.getTransport();
|
||||||
|
final Content.Senders senders = content.getSenders();
|
||||||
|
final RtpDescription rtpDescription;
|
||||||
|
final IceUdpTransportInfo iceUdpTransportInfo;
|
||||||
|
if (description == null) {
|
||||||
|
rtpDescription = null;
|
||||||
|
} else if (description instanceof RtpDescription) {
|
||||||
|
rtpDescription = (RtpDescription) description;
|
||||||
|
} else {
|
||||||
|
throw new UnsupportedApplicationException(
|
||||||
|
"Content does not contain rtp description");
|
||||||
|
}
|
||||||
|
if (transportInfo instanceof IceUdpTransportInfo) {
|
||||||
|
iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo;
|
||||||
|
} else {
|
||||||
|
throw new UnsupportedTransportException(
|
||||||
|
"Content does not contain ICE-UDP transport");
|
||||||
|
}
|
||||||
|
return new DescriptionTransport(
|
||||||
|
senders,
|
||||||
|
rtpDescription,
|
||||||
|
OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DescriptionTransport of(
|
||||||
|
final SessionDescription sessionDescription,
|
||||||
|
final boolean isInitiator,
|
||||||
|
final SessionDescription.Media media) {
|
||||||
|
final Content.Senders senders = Content.Senders.of(media, isInitiator);
|
||||||
|
final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
|
||||||
|
final IceUdpTransportInfo transportInfo =
|
||||||
|
IceUdpTransportInfo.of(sessionDescription, media);
|
||||||
|
return new DescriptionTransport(senders, rtpDescription, transportInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
|
||||||
|
return ImmutableMap.copyOf(
|
||||||
|
Maps.transformValues(
|
||||||
|
contents, content -> content == null ? null : of(content)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UnsupportedApplicationException extends IllegalArgumentException {
|
||||||
|
UnsupportedApplicationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UnsupportedTransportException extends IllegalArgumentException {
|
||||||
|
UnsupportedTransportException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Diff {
|
||||||
|
public final Set<String> added;
|
||||||
|
public final Set<String> removed;
|
||||||
|
|
||||||
|
private Diff(final Set<String> added, final Set<String> removed) {
|
||||||
|
this.added = added;
|
||||||
|
this.removed = removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasModifications() {
|
||||||
|
return !this.added.isEmpty() || !this.removed.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return this.added.isEmpty() && this.removed.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nonnull
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("added", added)
|
||||||
|
.add("removed", removed)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class DTLS {
|
||||||
|
public final String hash;
|
||||||
|
public final IceUdpTransportInfo.Setup setup;
|
||||||
|
public final String fingerprint;
|
||||||
|
|
||||||
|
private DTLS(String hash, IceUdpTransportInfo.Setup setup, String fingerprint) {
|
||||||
|
this.hash = hash;
|
||||||
|
this.setup = setup;
|
||||||
|
this.fingerprint = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
DTLS dtls = (DTLS) o;
|
||||||
|
return Objects.equal(hash, dtls.hash)
|
||||||
|
&& setup == dtls.setup
|
||||||
|
&& Objects.equal(fingerprint, dtls.fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(hash, setup, fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
public enum RtpEndUserState {
|
||||||
|
INCOMING_CALL, // received a 'propose' message
|
||||||
|
CONNECTING, // session-initiate or session-accepted but no webrtc peer connection yet
|
||||||
|
CONNECTED, // session-accepted and webrtc peer connection is connected
|
||||||
|
RECONNECTING, // session-accepted and webrtc peer connection was connected once but is currently
|
||||||
|
// disconnected or failed
|
||||||
|
INCOMING_CONTENT_ADD, // session-accepted with a pending, incoming content-add
|
||||||
|
FINDING_DEVICE, // 'propose' has been sent out; no 184 ack yet
|
||||||
|
RINGING, // 'propose' has been sent out and it has been 184 acked
|
||||||
|
ACCEPTING_CALL, // 'proceed' message has been sent; but no session-initiate has been received
|
||||||
|
ENDING_CALL, // libwebrt says 'closed' but session-terminate hasnt gone through
|
||||||
|
ENDED, // close UI
|
||||||
|
DECLINED_OR_BUSY, // other party declined; no retry button
|
||||||
|
CONNECTIVITY_ERROR, // network error; retry button
|
||||||
|
CONNECTIVITY_LOST_ERROR, // network error but for call duration > 0
|
||||||
|
RETRACTED, // user pressed home or power button during 'ringing' - shows retry button
|
||||||
|
APPLICATION_ERROR, // something rather bad happened; libwebrtc failed or we got in IQ-error
|
||||||
|
SECURITY_ERROR // problem with DTLS (missing) or verification
|
||||||
|
}
|
|
@ -0,0 +1,415 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import android.util.Pair;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import com.google.common.base.CharMatcher;
|
||||||
|
import com.google.common.base.Joiner;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class SessionDescription {
|
||||||
|
|
||||||
|
public static final String LINE_DIVIDER = "\r\n";
|
||||||
|
private static final String HARDCODED_MEDIA_PROTOCOL =
|
||||||
|
"UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
|
||||||
|
private static final int HARDCODED_MEDIA_PORT = 9;
|
||||||
|
private static final String HARDCODED_ICE_OPTIONS = "trickle";
|
||||||
|
private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
|
||||||
|
|
||||||
|
public final int version;
|
||||||
|
public final String name;
|
||||||
|
public final String connectionData;
|
||||||
|
public final ArrayListMultimap<String, String> attributes;
|
||||||
|
public final List<Media> media;
|
||||||
|
|
||||||
|
public SessionDescription(
|
||||||
|
int version,
|
||||||
|
String name,
|
||||||
|
String connectionData,
|
||||||
|
ArrayListMultimap<String, String> attributes,
|
||||||
|
List<Media> media) {
|
||||||
|
this.version = version;
|
||||||
|
this.name = name;
|
||||||
|
this.connectionData = connectionData;
|
||||||
|
this.attributes = attributes;
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendAttributes(
|
||||||
|
StringBuilder s, ArrayListMultimap<String, String> attributes) {
|
||||||
|
for (Map.Entry<String, String> attribute : attributes.entries()) {
|
||||||
|
final String key = attribute.getKey();
|
||||||
|
final String value = attribute.getValue();
|
||||||
|
s.append("a=").append(key);
|
||||||
|
if (!Strings.isNullOrEmpty(value)) {
|
||||||
|
s.append(':').append(value);
|
||||||
|
}
|
||||||
|
s.append(LINE_DIVIDER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SessionDescription parse(final String input) {
|
||||||
|
final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
|
||||||
|
MediaBuilder currentMediaBuilder = null;
|
||||||
|
ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
|
||||||
|
ImmutableList.Builder<Media> mediaBuilder = new ImmutableList.Builder<>();
|
||||||
|
for (final String line : input.split(LINE_DIVIDER)) {
|
||||||
|
final String[] pair = line.trim().split("=", 2);
|
||||||
|
if (pair.length < 2 || pair[0].length() != 1) {
|
||||||
|
Log.d(Config.LOGTAG, "skipping sdp parsing on line " + line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final char key = pair[0].charAt(0);
|
||||||
|
final String value = pair[1];
|
||||||
|
switch (key) {
|
||||||
|
case 'v':
|
||||||
|
sessionDescriptionBuilder.setVersion(ignorantIntParser(value));
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
if (currentMediaBuilder != null) {
|
||||||
|
currentMediaBuilder.setConnectionData(value);
|
||||||
|
} else {
|
||||||
|
sessionDescriptionBuilder.setConnectionData(value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
sessionDescriptionBuilder.setName(value);
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
final Pair<String, String> attribute = parseAttribute(value);
|
||||||
|
attributeMap.put(attribute.first, attribute.second);
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
if (currentMediaBuilder == null) {
|
||||||
|
sessionDescriptionBuilder.setAttributes(attributeMap);
|
||||||
|
} else {
|
||||||
|
currentMediaBuilder.setAttributes(attributeMap);
|
||||||
|
mediaBuilder.add(currentMediaBuilder.createMedia());
|
||||||
|
}
|
||||||
|
attributeMap = ArrayListMultimap.create();
|
||||||
|
currentMediaBuilder = new MediaBuilder();
|
||||||
|
final String[] parts = value.split(" ");
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
currentMediaBuilder.setMedia(parts[0]);
|
||||||
|
currentMediaBuilder.setPort(ignorantIntParser(parts[1]));
|
||||||
|
currentMediaBuilder.setProtocol(parts[2]);
|
||||||
|
ImmutableList.Builder<Integer> formats = new ImmutableList.Builder<>();
|
||||||
|
for (int i = 3; i < parts.length; ++i) {
|
||||||
|
formats.add(ignorantIntParser(parts[i]));
|
||||||
|
}
|
||||||
|
currentMediaBuilder.setFormats(formats.build());
|
||||||
|
} else {
|
||||||
|
Log.d(Config.LOGTAG, "skipping media line " + line);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentMediaBuilder != null) {
|
||||||
|
currentMediaBuilder.setAttributes(attributeMap);
|
||||||
|
mediaBuilder.add(currentMediaBuilder.createMedia());
|
||||||
|
} else {
|
||||||
|
sessionDescriptionBuilder.setAttributes(attributeMap);
|
||||||
|
}
|
||||||
|
sessionDescriptionBuilder.setMedia(mediaBuilder.build());
|
||||||
|
return sessionDescriptionBuilder.createSessionDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SessionDescription of(
|
||||||
|
final RtpContentMap contentMap, final boolean isInitiatorContentMap) {
|
||||||
|
final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
|
||||||
|
final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
|
||||||
|
final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
|
||||||
|
final Group group = contentMap.group;
|
||||||
|
if (group != null) {
|
||||||
|
final String semantics = group.getSemantics();
|
||||||
|
checkNoWhitespace(semantics, "group semantics value must not contain any whitespace");
|
||||||
|
attributeMap.put(
|
||||||
|
"group",
|
||||||
|
group.getSemantics()
|
||||||
|
+ " "
|
||||||
|
+ Joiner.on(' ').join(group.getIdentificationTags()));
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeMap.put("msid-semantic", " WMS my-media-stream");
|
||||||
|
|
||||||
|
for (final Map.Entry<String, RtpContentMap.DescriptionTransport> entry :
|
||||||
|
contentMap.contents.entrySet()) {
|
||||||
|
final String name = entry.getKey();
|
||||||
|
RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue();
|
||||||
|
RtpDescription description = descriptionTransport.description;
|
||||||
|
IceUdpTransportInfo transport = descriptionTransport.transport;
|
||||||
|
final ArrayListMultimap<String, String> mediaAttributes = ArrayListMultimap.create();
|
||||||
|
final String ufrag = transport.getAttribute("ufrag");
|
||||||
|
final String pwd = transport.getAttribute("pwd");
|
||||||
|
if (Strings.isNullOrEmpty(ufrag)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Transport element is missing required ufrag attribute");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces");
|
||||||
|
mediaAttributes.put("ice-ufrag", ufrag);
|
||||||
|
if (Strings.isNullOrEmpty(pwd)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Transport element is missing required pwd attribute");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
|
||||||
|
mediaAttributes.put("ice-pwd", pwd);
|
||||||
|
mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
|
||||||
|
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
|
||||||
|
if (fingerprint != null) {
|
||||||
|
mediaAttributes.put(
|
||||||
|
"fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
|
||||||
|
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
|
||||||
|
if (setup != null) {
|
||||||
|
mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final ImmutableList.Builder<Integer> formatBuilder = new ImmutableList.Builder<>();
|
||||||
|
for (RtpDescription.PayloadType payloadType : description.getPayloadTypes()) {
|
||||||
|
final String id = payloadType.getId();
|
||||||
|
if (Strings.isNullOrEmpty(id)) {
|
||||||
|
throw new IllegalArgumentException("Payload type is missing id");
|
||||||
|
}
|
||||||
|
if (!isInt(id)) {
|
||||||
|
throw new IllegalArgumentException("Payload id is not numeric");
|
||||||
|
}
|
||||||
|
formatBuilder.add(payloadType.getIntId());
|
||||||
|
mediaAttributes.put("rtpmap", payloadType.toSdpAttribute());
|
||||||
|
final List<RtpDescription.Parameter> parameters = payloadType.getParameters();
|
||||||
|
if (parameters.size() == 1) {
|
||||||
|
mediaAttributes.put(
|
||||||
|
"fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
|
||||||
|
} else if (parameters.size() > 0) {
|
||||||
|
mediaAttributes.put(
|
||||||
|
"fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
|
||||||
|
}
|
||||||
|
for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
|
||||||
|
payloadType.getFeedbackNegotiations()) {
|
||||||
|
final String type = feedbackNegotiation.getType();
|
||||||
|
final String subtype = feedbackNegotiation.getSubType();
|
||||||
|
if (Strings.isNullOrEmpty(type)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"a feedback for payload-type "
|
||||||
|
+ id
|
||||||
|
+ " negotiation is missing type");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(
|
||||||
|
type, "feedback negotiation type must not contain whitespace");
|
||||||
|
mediaAttributes.put(
|
||||||
|
"rtcp-fb",
|
||||||
|
id
|
||||||
|
+ " "
|
||||||
|
+ type
|
||||||
|
+ (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
|
||||||
|
}
|
||||||
|
for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
||||||
|
payloadType.feedbackNegotiationTrrInts()) {
|
||||||
|
mediaAttributes.put(
|
||||||
|
"rtcp-fb", id + " trr-int " + feedbackNegotiationTrrInt.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (RtpDescription.FeedbackNegotiation feedbackNegotiation :
|
||||||
|
description.getFeedbackNegotiations()) {
|
||||||
|
final String type = feedbackNegotiation.getType();
|
||||||
|
final String subtype = feedbackNegotiation.getSubType();
|
||||||
|
if (Strings.isNullOrEmpty(type)) {
|
||||||
|
throw new IllegalArgumentException("a feedback negotiation is missing type");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
|
||||||
|
mediaAttributes.put(
|
||||||
|
"rtcp-fb",
|
||||||
|
"* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
|
||||||
|
}
|
||||||
|
for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
||||||
|
description.feedbackNegotiationTrrInts()) {
|
||||||
|
mediaAttributes.put("rtcp-fb", "* trr-int " + feedbackNegotiationTrrInt.getValue());
|
||||||
|
}
|
||||||
|
for (final RtpDescription.RtpHeaderExtension extension :
|
||||||
|
description.getHeaderExtensions()) {
|
||||||
|
final String id = extension.getId();
|
||||||
|
final String uri = extension.getUri();
|
||||||
|
if (Strings.isNullOrEmpty(id)) {
|
||||||
|
throw new IllegalArgumentException("A header extension is missing id");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(id, "header extension id must not contain whitespace");
|
||||||
|
if (Strings.isNullOrEmpty(uri)) {
|
||||||
|
throw new IllegalArgumentException("A header extension is missing uri");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(uri, "feedback negotiation uri must not contain whitespace");
|
||||||
|
mediaAttributes.put("extmap", id + " " + uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.hasChild(
|
||||||
|
"extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
|
||||||
|
mediaAttributes.put("extmap-allow-mixed", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final RtpDescription.SourceGroup sourceGroup : description.getSourceGroups()) {
|
||||||
|
final String semantics = sourceGroup.getSemantics();
|
||||||
|
final List<String> groups = sourceGroup.getSsrcs();
|
||||||
|
if (Strings.isNullOrEmpty(semantics)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"A SSRC group is missing semantics attribute");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(semantics, "source group semantics must not contain whitespace");
|
||||||
|
if (groups.size() == 0) {
|
||||||
|
throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
|
||||||
|
}
|
||||||
|
mediaAttributes.put(
|
||||||
|
"ssrc-group",
|
||||||
|
String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
|
||||||
|
}
|
||||||
|
for (final RtpDescription.Source source : description.getSources()) {
|
||||||
|
for (final RtpDescription.Source.Parameter parameter : source.getParameters()) {
|
||||||
|
final String id = source.getSsrcId();
|
||||||
|
final String parameterName = parameter.getParameterName();
|
||||||
|
final String parameterValue = parameter.getParameterValue();
|
||||||
|
if (Strings.isNullOrEmpty(id)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"A source specific media attribute is missing the id");
|
||||||
|
}
|
||||||
|
checkNoWhitespace(
|
||||||
|
id, "A source specific media attributes must not contain whitespaces");
|
||||||
|
if (Strings.isNullOrEmpty(parameterName)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"A source specific media attribute is missing its name");
|
||||||
|
}
|
||||||
|
if (Strings.isNullOrEmpty(parameterValue)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"A source specific media attribute is missing its value");
|
||||||
|
}
|
||||||
|
mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaAttributes.put("mid", name);
|
||||||
|
|
||||||
|
mediaAttributes.put(
|
||||||
|
descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), "");
|
||||||
|
if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) {
|
||||||
|
mediaAttributes.put("rtcp-mux", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// random additional attributes
|
||||||
|
mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
|
||||||
|
|
||||||
|
final MediaBuilder mediaBuilder = new MediaBuilder();
|
||||||
|
mediaBuilder.setMedia(description.getMedia().toString().toLowerCase(Locale.ROOT));
|
||||||
|
mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
|
||||||
|
mediaBuilder.setPort(HARDCODED_MEDIA_PORT);
|
||||||
|
mediaBuilder.setProtocol(HARDCODED_MEDIA_PROTOCOL);
|
||||||
|
mediaBuilder.setAttributes(mediaAttributes);
|
||||||
|
mediaBuilder.setFormats(formatBuilder.build());
|
||||||
|
mediaListBuilder.add(mediaBuilder.createMedia());
|
||||||
|
}
|
||||||
|
sessionDescriptionBuilder.setVersion(0);
|
||||||
|
sessionDescriptionBuilder.setName("-");
|
||||||
|
sessionDescriptionBuilder.setMedia(mediaListBuilder.build());
|
||||||
|
sessionDescriptionBuilder.setAttributes(attributeMap);
|
||||||
|
|
||||||
|
return sessionDescriptionBuilder.createSessionDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String checkNoWhitespace(final String input, final String message) {
|
||||||
|
if (CharMatcher.whitespace().matchesAnyOf(input)) {
|
||||||
|
throw new IllegalArgumentException(message);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int ignorantIntParser(final String input) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(input);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isInt(final String input) {
|
||||||
|
if (input == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Integer.parseInt(input);
|
||||||
|
return true;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Pair<String, String> parseAttribute(final String input) {
|
||||||
|
final String[] pair = input.split(":", 2);
|
||||||
|
if (pair.length == 2) {
|
||||||
|
return new Pair<>(pair[0], pair[1]);
|
||||||
|
} else {
|
||||||
|
return new Pair<>(pair[0], "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
final StringBuilder s =
|
||||||
|
new StringBuilder()
|
||||||
|
.append("v=")
|
||||||
|
.append(version)
|
||||||
|
.append(LINE_DIVIDER)
|
||||||
|
// TODO randomize or static
|
||||||
|
.append("o=- 8770656990916039506 2 IN IP4 127.0.0.1")
|
||||||
|
.append(LINE_DIVIDER) // what ever that means
|
||||||
|
.append("s=")
|
||||||
|
.append(name)
|
||||||
|
.append(LINE_DIVIDER)
|
||||||
|
.append("t=0 0")
|
||||||
|
.append(LINE_DIVIDER);
|
||||||
|
appendAttributes(s, attributes);
|
||||||
|
for (Media media : this.media) {
|
||||||
|
s.append("m=")
|
||||||
|
.append(media.media)
|
||||||
|
.append(' ')
|
||||||
|
.append(media.port)
|
||||||
|
.append(' ')
|
||||||
|
.append(media.protocol)
|
||||||
|
.append(' ')
|
||||||
|
.append(Joiner.on(' ').join(media.formats))
|
||||||
|
.append(LINE_DIVIDER);
|
||||||
|
s.append("c=").append(media.connectionData).append(LINE_DIVIDER);
|
||||||
|
appendAttributes(s, media.attributes);
|
||||||
|
}
|
||||||
|
return s.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Media {
|
||||||
|
public final String media;
|
||||||
|
public final int port;
|
||||||
|
public final String protocol;
|
||||||
|
public final List<Integer> formats;
|
||||||
|
public final String connectionData;
|
||||||
|
public final ArrayListMultimap<String, String> attributes;
|
||||||
|
|
||||||
|
public Media(
|
||||||
|
String media,
|
||||||
|
int port,
|
||||||
|
String protocol,
|
||||||
|
List<Integer> formats,
|
||||||
|
String connectionData,
|
||||||
|
ArrayListMultimap<String, String> attributes) {
|
||||||
|
this.media = media;
|
||||||
|
this.port = port;
|
||||||
|
this.protocol = protocol;
|
||||||
|
this.formats = formats;
|
||||||
|
this.connectionData = connectionData;
|
||||||
|
this.attributes = attributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class SessionDescriptionBuilder {
|
||||||
|
private int version;
|
||||||
|
private String name;
|
||||||
|
private String connectionData;
|
||||||
|
private ArrayListMultimap<String, String> attributes;
|
||||||
|
private List<SessionDescription.Media> media;
|
||||||
|
|
||||||
|
public SessionDescriptionBuilder setVersion(int version) {
|
||||||
|
this.version = version;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionDescriptionBuilder setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionDescriptionBuilder setConnectionData(String connectionData) {
|
||||||
|
this.connectionData = connectionData;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionDescriptionBuilder setAttributes(ArrayListMultimap<String, String> attributes) {
|
||||||
|
this.attributes = attributes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionDescriptionBuilder setMedia(List<SessionDescription.Media> media) {
|
||||||
|
this.media = media;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionDescription createSessionDescription() {
|
||||||
|
return new SessionDescription(version, name, connectionData, attributes, media);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,274 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import static java.util.Arrays.asList;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.ToneGenerator;
|
||||||
|
import android.util.Log;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import im.conversations.android.xmpp.manager.JingleConnectionManager;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public class ToneManager {
|
||||||
|
|
||||||
|
private final ToneGenerator toneGenerator;
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
private ToneState state = null;
|
||||||
|
private RtpEndUserState endUserState = null;
|
||||||
|
private ScheduledFuture<?> currentTone;
|
||||||
|
private ScheduledFuture<?> currentResetFuture;
|
||||||
|
private boolean appRtcAudioManagerHasControl = false;
|
||||||
|
|
||||||
|
private static volatile ToneManager INSTANCE;
|
||||||
|
|
||||||
|
private ToneManager(final Context context) {
|
||||||
|
ToneGenerator toneGenerator;
|
||||||
|
try {
|
||||||
|
toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 60);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
Log.e(Config.LOGTAG, "unable to instantiate ToneGenerator", e);
|
||||||
|
toneGenerator = null;
|
||||||
|
}
|
||||||
|
this.toneGenerator = toneGenerator;
|
||||||
|
this.context = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToneState of(
|
||||||
|
final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
|
||||||
|
if (isInitiator) {
|
||||||
|
if (asList(
|
||||||
|
RtpEndUserState.FINDING_DEVICE,
|
||||||
|
RtpEndUserState.RINGING,
|
||||||
|
RtpEndUserState.CONNECTING)
|
||||||
|
.contains(state)) {
|
||||||
|
return ToneState.RINGING;
|
||||||
|
}
|
||||||
|
if (state == RtpEndUserState.DECLINED_OR_BUSY) {
|
||||||
|
return ToneState.BUSY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state == RtpEndUserState.ENDING_CALL) {
|
||||||
|
if (media.contains(Media.VIDEO)) {
|
||||||
|
return ToneState.NULL;
|
||||||
|
} else {
|
||||||
|
return ToneState.ENDING_CALL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Arrays.asList(
|
||||||
|
RtpEndUserState.CONNECTED,
|
||||||
|
RtpEndUserState.RECONNECTING,
|
||||||
|
RtpEndUserState.INCOMING_CONTENT_ADD)
|
||||||
|
.contains(state)) {
|
||||||
|
if (media.contains(Media.VIDEO)) {
|
||||||
|
return ToneState.NULL;
|
||||||
|
} else {
|
||||||
|
return ToneState.CONNECTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ToneState.NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void transition(final RtpEndUserState state, final Set<Media> media) {
|
||||||
|
transition(state, of(true, state, media), media);
|
||||||
|
}
|
||||||
|
|
||||||
|
void transition(
|
||||||
|
final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
|
||||||
|
transition(state, of(isInitiator, state, media), media);
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void transition(
|
||||||
|
final RtpEndUserState endUserState, final ToneState state, final Set<Media> media) {
|
||||||
|
final RtpEndUserState normalizeEndUserState = normalize(endUserState);
|
||||||
|
if (this.endUserState == normalizeEndUserState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.endUserState = normalizeEndUserState;
|
||||||
|
if (this.state == state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state == ToneState.NULL && this.state == ToneState.ENDING_CALL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelCurrentTone();
|
||||||
|
Log.d(Config.LOGTAG, getClass().getName() + ".transition(" + state + ")");
|
||||||
|
if (state != ToneState.NULL) {
|
||||||
|
configureAudioManagerForCall(media);
|
||||||
|
}
|
||||||
|
switch (state) {
|
||||||
|
case RINGING:
|
||||||
|
scheduleWaitingTone();
|
||||||
|
break;
|
||||||
|
case CONNECTED:
|
||||||
|
scheduleConnected();
|
||||||
|
break;
|
||||||
|
case BUSY:
|
||||||
|
scheduleBusy();
|
||||||
|
break;
|
||||||
|
case ENDING_CALL:
|
||||||
|
scheduleEnding();
|
||||||
|
break;
|
||||||
|
case NULL:
|
||||||
|
if (noResetScheduled()) {
|
||||||
|
resetAudioManager();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalStateException("Unable to handle transition to " + state);
|
||||||
|
}
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RtpEndUserState normalize(final RtpEndUserState endUserState) {
|
||||||
|
if (Arrays.asList(
|
||||||
|
RtpEndUserState.CONNECTED,
|
||||||
|
RtpEndUserState.RECONNECTING,
|
||||||
|
RtpEndUserState.INCOMING_CONTENT_ADD)
|
||||||
|
.contains(endUserState)) {
|
||||||
|
return RtpEndUserState.CONNECTED;
|
||||||
|
} else {
|
||||||
|
return endUserState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAppRtcAudioManagerHasControl(final boolean appRtcAudioManagerHasControl) {
|
||||||
|
this.appRtcAudioManagerHasControl = appRtcAudioManagerHasControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleConnected() {
|
||||||
|
this.currentTone =
|
||||||
|
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
|
||||||
|
() -> {
|
||||||
|
startTone(ToneGenerator.TONE_PROP_PROMPT, 200);
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleEnding() {
|
||||||
|
this.currentTone =
|
||||||
|
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
|
||||||
|
() -> {
|
||||||
|
startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
this.currentResetFuture =
|
||||||
|
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
|
||||||
|
this::resetAudioManager, 375, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleBusy() {
|
||||||
|
this.currentTone =
|
||||||
|
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
|
||||||
|
() -> {
|
||||||
|
startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
this.currentResetFuture =
|
||||||
|
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
|
||||||
|
this::resetAudioManager, 2500, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleWaitingTone() {
|
||||||
|
this.currentTone =
|
||||||
|
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(
|
||||||
|
() -> {
|
||||||
|
startTone(ToneGenerator.TONE_CDMA_DIAL_TONE_LITE, 750);
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean noResetScheduled() {
|
||||||
|
return this.currentResetFuture == null || this.currentResetFuture.isDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelCurrentTone() {
|
||||||
|
if (currentTone != null) {
|
||||||
|
currentTone.cancel(true);
|
||||||
|
}
|
||||||
|
if (toneGenerator != null) {
|
||||||
|
toneGenerator.stopTone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startTone(final int toneType, final int durationMs) {
|
||||||
|
if (toneGenerator != null) {
|
||||||
|
this.toneGenerator.startTone(toneType, durationMs);
|
||||||
|
} else {
|
||||||
|
Log.e(Config.LOGTAG, "failed to start tone. ToneGenerator doesn't exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureAudioManagerForCall(final Set<Media> media) {
|
||||||
|
if (appRtcAudioManagerHasControl) {
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
ToneManager.class.getName()
|
||||||
|
+ ": do not configure audio manager because RTC has control");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final AudioManager audioManager =
|
||||||
|
(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||||
|
if (audioManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean isSpeakerPhone = media.contains(Media.VIDEO);
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
ToneManager.class.getName()
|
||||||
|
+ ": putting AudioManager into communication mode. speaker="
|
||||||
|
+ isSpeakerPhone);
|
||||||
|
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||||
|
audioManager.setSpeakerphoneOn(isSpeakerPhone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetAudioManager() {
|
||||||
|
if (appRtcAudioManagerHasControl) {
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
ToneManager.class.getName()
|
||||||
|
+ ": do not reset audio manager because RTC has control");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final AudioManager audioManager =
|
||||||
|
(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||||
|
if (audioManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
ToneManager.class.getName() + ": putting AudioManager back into normal mode");
|
||||||
|
audioManager.setMode(AudioManager.MODE_NORMAL);
|
||||||
|
audioManager.setSpeakerphoneOn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ToneManager getInstance(final Context context) {
|
||||||
|
if (INSTANCE != null) {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
synchronized (ToneManager.class) {
|
||||||
|
if (INSTANCE != null) {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
INSTANCE = new ToneManager(context);
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ToneState {
|
||||||
|
NULL,
|
||||||
|
RINGING,
|
||||||
|
CONNECTED,
|
||||||
|
BUSY,
|
||||||
|
ENDING_CALL
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import com.google.common.base.CaseFormat;
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import org.webrtc.MediaStreamTrack;
|
||||||
|
import org.webrtc.PeerConnection;
|
||||||
|
import org.webrtc.RtpSender;
|
||||||
|
import org.webrtc.RtpTransceiver;
|
||||||
|
|
||||||
|
class TrackWrapper<T extends MediaStreamTrack> {
|
||||||
|
public final T track;
|
||||||
|
public final RtpSender rtpSender;
|
||||||
|
|
||||||
|
private TrackWrapper(final T track, final RtpSender rtpSender) {
|
||||||
|
Preconditions.checkNotNull(track);
|
||||||
|
Preconditions.checkNotNull(rtpSender);
|
||||||
|
this.track = track;
|
||||||
|
this.rtpSender = rtpSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T extends MediaStreamTrack> TrackWrapper<T> addTrack(
|
||||||
|
final PeerConnection peerConnection, final T mediaStreamTrack) {
|
||||||
|
final RtpSender rtpSender = peerConnection.addTrack(mediaStreamTrack);
|
||||||
|
return new TrackWrapper<>(mediaStreamTrack, rtpSender);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T extends MediaStreamTrack> Optional<T> get(
|
||||||
|
@Nullable final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
|
||||||
|
if (trackWrapper == null) {
|
||||||
|
return Optional.absent();
|
||||||
|
}
|
||||||
|
final RtpTransceiver transceiver =
|
||||||
|
peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
|
||||||
|
if (transceiver == null) {
|
||||||
|
Log.w(Config.LOGTAG, "unable to detect transceiver for " + trackWrapper.rtpSender.id());
|
||||||
|
return Optional.of(trackWrapper.track);
|
||||||
|
}
|
||||||
|
final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection();
|
||||||
|
if (direction == RtpTransceiver.RtpTransceiverDirection.SEND_ONLY
|
||||||
|
|| direction == RtpTransceiver.RtpTransceiverDirection.SEND_RECV) {
|
||||||
|
return Optional.of(trackWrapper.track);
|
||||||
|
} else {
|
||||||
|
Log.d(Config.LOGTAG, "withholding track because transceiver is " + direction);
|
||||||
|
return Optional.absent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
|
||||||
|
@Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
|
||||||
|
final RtpSender rtpSender = trackWrapper.rtpSender;
|
||||||
|
for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) {
|
||||||
|
if (transceiver.getSender().id().equals(rtpSender.id())) {
|
||||||
|
return transceiver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String id(final Class<? extends MediaStreamTrack> clazz) {
|
||||||
|
return String.format(
|
||||||
|
"%s-%s",
|
||||||
|
CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, clazz.getSimpleName()),
|
||||||
|
UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,182 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import org.webrtc.Camera2Enumerator;
|
||||||
|
import org.webrtc.CameraEnumerationAndroid;
|
||||||
|
import org.webrtc.CameraEnumerator;
|
||||||
|
import org.webrtc.CameraVideoCapturer;
|
||||||
|
import org.webrtc.EglBase;
|
||||||
|
import org.webrtc.PeerConnectionFactory;
|
||||||
|
import org.webrtc.SurfaceTextureHelper;
|
||||||
|
import org.webrtc.VideoSource;
|
||||||
|
|
||||||
|
class VideoSourceWrapper {
|
||||||
|
|
||||||
|
private static final int CAPTURING_RESOLUTION = 1920;
|
||||||
|
private static final int CAPTURING_MAX_FRAME_RATE = 30;
|
||||||
|
|
||||||
|
private final CameraVideoCapturer cameraVideoCapturer;
|
||||||
|
private final CameraEnumerationAndroid.CaptureFormat captureFormat;
|
||||||
|
private final Set<String> availableCameras;
|
||||||
|
private boolean isFrontCamera = false;
|
||||||
|
private VideoSource videoSource;
|
||||||
|
|
||||||
|
VideoSourceWrapper(
|
||||||
|
CameraVideoCapturer cameraVideoCapturer,
|
||||||
|
CameraEnumerationAndroid.CaptureFormat captureFormat,
|
||||||
|
Set<String> cameras) {
|
||||||
|
this.cameraVideoCapturer = cameraVideoCapturer;
|
||||||
|
this.captureFormat = captureFormat;
|
||||||
|
this.availableCameras = cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getFrameRate() {
|
||||||
|
return Math.max(
|
||||||
|
captureFormat.framerate.min,
|
||||||
|
Math.min(CAPTURING_MAX_FRAME_RATE, captureFormat.framerate.max));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void initialize(
|
||||||
|
final PeerConnectionFactory peerConnectionFactory,
|
||||||
|
final Context context,
|
||||||
|
final EglBase.Context eglBaseContext) {
|
||||||
|
final SurfaceTextureHelper surfaceTextureHelper =
|
||||||
|
SurfaceTextureHelper.create("webrtc", eglBaseContext);
|
||||||
|
this.videoSource = peerConnectionFactory.createVideoSource(false);
|
||||||
|
this.cameraVideoCapturer.initialize(
|
||||||
|
surfaceTextureHelper, context, this.videoSource.getCapturerObserver());
|
||||||
|
}
|
||||||
|
|
||||||
|
public VideoSource getVideoSource() {
|
||||||
|
final VideoSource videoSource = this.videoSource;
|
||||||
|
if (videoSource == null) {
|
||||||
|
throw new IllegalStateException("VideoSourceWrapper was not initialized");
|
||||||
|
}
|
||||||
|
return videoSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startCapture() {
|
||||||
|
final int frameRate = getFrameRate();
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
String.format(
|
||||||
|
"start capturing at %dx%d@%d",
|
||||||
|
captureFormat.width, captureFormat.height, frameRate));
|
||||||
|
this.cameraVideoCapturer.startCapture(captureFormat.width, captureFormat.height, frameRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopCapture() throws InterruptedException {
|
||||||
|
this.cameraVideoCapturer.stopCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void dispose() {
|
||||||
|
this.cameraVideoCapturer.dispose();
|
||||||
|
if (this.videoSource != null) {
|
||||||
|
dispose(this.videoSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void dispose(final VideoSource videoSource) {
|
||||||
|
try {
|
||||||
|
videoSource.dispose();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
Log.e(Config.LOGTAG, "unable to dispose video source", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<Boolean> switchCamera() {
|
||||||
|
final SettableFuture<Boolean> future = SettableFuture.create();
|
||||||
|
this.cameraVideoCapturer.switchCamera(
|
||||||
|
new CameraVideoCapturer.CameraSwitchHandler() {
|
||||||
|
@Override
|
||||||
|
public void onCameraSwitchDone(final boolean isFrontCamera) {
|
||||||
|
VideoSourceWrapper.this.isFrontCamera = isFrontCamera;
|
||||||
|
future.set(isFrontCamera);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCameraSwitchError(final String message) {
|
||||||
|
future.setException(
|
||||||
|
new IllegalStateException(
|
||||||
|
String.format("Unable to switch camera %s", message)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFrontCamera() {
|
||||||
|
return this.isFrontCamera;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCameraSwitchable() {
|
||||||
|
return this.availableCameras.size() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Factory {
|
||||||
|
final Context context;
|
||||||
|
|
||||||
|
public Factory(final Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VideoSourceWrapper create() {
|
||||||
|
final CameraEnumerator enumerator = new Camera2Enumerator(context);
|
||||||
|
final Set<String> deviceNames = ImmutableSet.copyOf(enumerator.getDeviceNames());
|
||||||
|
for (final String deviceName : deviceNames) {
|
||||||
|
if (isFrontFacing(enumerator, deviceName)) {
|
||||||
|
final VideoSourceWrapper videoSourceWrapper =
|
||||||
|
of(enumerator, deviceName, deviceNames);
|
||||||
|
if (videoSourceWrapper == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
videoSourceWrapper.isFrontCamera = true;
|
||||||
|
return videoSourceWrapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deviceNames.size() == 0) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return of(enumerator, Iterables.get(deviceNames, 0), deviceNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private VideoSourceWrapper of(
|
||||||
|
final CameraEnumerator enumerator,
|
||||||
|
final String deviceName,
|
||||||
|
final Set<String> availableCameras) {
|
||||||
|
final CameraVideoCapturer capturer = enumerator.createCapturer(deviceName, null);
|
||||||
|
if (capturer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final ArrayList<CameraEnumerationAndroid.CaptureFormat> choices =
|
||||||
|
new ArrayList<>(enumerator.getSupportedFormats(deviceName));
|
||||||
|
Collections.sort(choices, (a, b) -> b.width - a.width);
|
||||||
|
for (final CameraEnumerationAndroid.CaptureFormat captureFormat : choices) {
|
||||||
|
if (captureFormat.width <= CAPTURING_RESOLUTION) {
|
||||||
|
return new VideoSourceWrapper(capturer, captureFormat, availableCameras);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isFrontFacing(
|
||||||
|
final CameraEnumerator cameraEnumerator, final String deviceName) {
|
||||||
|
try {
|
||||||
|
return cameraEnumerator.isFrontFacing(deviceName);
|
||||||
|
} catch (final NullPointerException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,750 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.services.AppRTCAudioManager;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import org.webrtc.AudioSource;
|
||||||
|
import org.webrtc.AudioTrack;
|
||||||
|
import org.webrtc.CandidatePairChangeEvent;
|
||||||
|
import org.webrtc.DataChannel;
|
||||||
|
import org.webrtc.DefaultVideoDecoderFactory;
|
||||||
|
import org.webrtc.DefaultVideoEncoderFactory;
|
||||||
|
import org.webrtc.EglBase;
|
||||||
|
import org.webrtc.IceCandidate;
|
||||||
|
import org.webrtc.MediaConstraints;
|
||||||
|
import org.webrtc.MediaStream;
|
||||||
|
import org.webrtc.MediaStreamTrack;
|
||||||
|
import org.webrtc.PeerConnection;
|
||||||
|
import org.webrtc.PeerConnectionFactory;
|
||||||
|
import org.webrtc.RtpReceiver;
|
||||||
|
import org.webrtc.RtpTransceiver;
|
||||||
|
import org.webrtc.SdpObserver;
|
||||||
|
import org.webrtc.SessionDescription;
|
||||||
|
import org.webrtc.VideoTrack;
|
||||||
|
import org.webrtc.audio.JavaAudioDeviceModule;
|
||||||
|
import org.webrtc.voiceengine.WebRtcAudioEffects;
|
||||||
|
|
||||||
|
@SuppressWarnings("UnstableApiUsage")
|
||||||
|
public class WebRTCWrapper {
|
||||||
|
|
||||||
|
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
|
||||||
|
|
||||||
|
private final ExecutorService executorService = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
|
private static final Set<String> HARDWARE_AEC_BLACKLIST =
|
||||||
|
new ImmutableSet.Builder<String>()
|
||||||
|
.add("Pixel")
|
||||||
|
.add("Pixel XL")
|
||||||
|
.add("Moto G5")
|
||||||
|
.add("Moto G (5S) Plus")
|
||||||
|
.add("Moto G4")
|
||||||
|
.add("TA-1053")
|
||||||
|
.add("Mi A1")
|
||||||
|
.add("Mi A2")
|
||||||
|
.add("E5823") // Sony z5 compact
|
||||||
|
.add("Redmi Note 5")
|
||||||
|
.add("FP2") // Fairphone FP2
|
||||||
|
.add("MI 5")
|
||||||
|
.add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
|
||||||
|
.add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
|
||||||
|
.add("GT-I9505") // Samsung Galaxy S4 (jfltexx)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private final EventCallback eventCallback;
|
||||||
|
private final AtomicBoolean readyToReceivedIceCandidates = new AtomicBoolean(false);
|
||||||
|
private final Queue<IceCandidate> iceCandidates = new LinkedList<>();
|
||||||
|
private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents =
|
||||||
|
new AppRTCAudioManager.AudioManagerEvents() {
|
||||||
|
@Override
|
||||||
|
public void onAudioDeviceChanged(
|
||||||
|
AppRTCAudioManager.AudioDevice selectedAudioDevice,
|
||||||
|
Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
|
||||||
|
eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private TrackWrapper<AudioTrack> localAudioTrack = null;
|
||||||
|
private TrackWrapper<VideoTrack> localVideoTrack = null;
|
||||||
|
private VideoTrack remoteVideoTrack = null;
|
||||||
|
private final PeerConnection.Observer peerConnectionObserver =
|
||||||
|
new PeerConnection.Observer() {
|
||||||
|
@Override
|
||||||
|
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, "onSignalingChange(" + signalingState + ")");
|
||||||
|
// this is called after removeTrack or addTrack
|
||||||
|
// and should then trigger a content-add or content-remove or something
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/removeTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
|
||||||
|
eventCallback.onConnectionChange(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceConnectionChange(
|
||||||
|
PeerConnection.IceConnectionState iceConnectionState) {
|
||||||
|
Log.d(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"onIceConnectionChange(" + iceConnectionState + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) {
|
||||||
|
Log.d(Config.LOGTAG, "remote candidate selected: " + event.remote);
|
||||||
|
Log.d(Config.LOGTAG, "local candidate selected: " + event.local);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceConnectionReceivingChange(boolean b) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceGatheringChange(
|
||||||
|
PeerConnection.IceGatheringState iceGatheringState) {
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceCandidate(IceCandidate iceCandidate) {
|
||||||
|
if (readyToReceivedIceCandidates.get()) {
|
||||||
|
eventCallback.onIceCandidate(iceCandidate);
|
||||||
|
} else {
|
||||||
|
iceCandidates.add(iceCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAddStream(MediaStream mediaStream) {
|
||||||
|
Log.d(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"onAddStream(numAudioTracks="
|
||||||
|
+ mediaStream.audioTracks.size()
|
||||||
|
+ ",numVideoTracks="
|
||||||
|
+ mediaStream.videoTracks.size()
|
||||||
|
+ ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRemoveStream(MediaStream mediaStream) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDataChannel(DataChannel dataChannel) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRenegotiationNeeded() {
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, "onRenegotiationNeeded()");
|
||||||
|
final PeerConnection.PeerConnectionState currentState =
|
||||||
|
peerConnection == null ? null : peerConnection.connectionState();
|
||||||
|
if (currentState != null
|
||||||
|
&& currentState != PeerConnection.PeerConnectionState.NEW) {
|
||||||
|
eventCallback.onRenegotiationNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
|
||||||
|
final MediaStreamTrack track = rtpReceiver.track();
|
||||||
|
Log.d(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"onAddTrack(kind="
|
||||||
|
+ (track == null ? "null" : track.kind())
|
||||||
|
+ ",numMediaStreams="
|
||||||
|
+ mediaStreams.length
|
||||||
|
+ ")");
|
||||||
|
if (track instanceof VideoTrack) {
|
||||||
|
remoteVideoTrack = (VideoTrack) track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTrack(final RtpTransceiver transceiver) {
|
||||||
|
Log.d(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"onTrack(mid="
|
||||||
|
+ transceiver.getMid()
|
||||||
|
+ ",media="
|
||||||
|
+ transceiver.getMediaType()
|
||||||
|
+ ",direction="
|
||||||
|
+ transceiver.getDirection()
|
||||||
|
+ ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRemoveTrack(final RtpReceiver receiver) {
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, "onRemoveTrack(" + receiver.id() + ")");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@Nullable private PeerConnectionFactory peerConnectionFactory = null;
|
||||||
|
@Nullable private PeerConnection peerConnection = null;
|
||||||
|
private AppRTCAudioManager appRTCAudioManager = null;
|
||||||
|
private Context context = null;
|
||||||
|
private EglBase eglBase = null;
|
||||||
|
private VideoSourceWrapper videoSourceWrapper;
|
||||||
|
|
||||||
|
WebRTCWrapper(final EventCallback eventCallback) {
|
||||||
|
this.eventCallback = eventCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void dispose(final PeerConnection peerConnection) {
|
||||||
|
try {
|
||||||
|
peerConnection.dispose();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
Log.e(Config.LOGTAG, "unable to dispose of peer connection", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setup(
|
||||||
|
final Context service,
|
||||||
|
@Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
|
||||||
|
throws InitializationException {
|
||||||
|
try {
|
||||||
|
PeerConnectionFactory.initialize(
|
||||||
|
PeerConnectionFactory.InitializationOptions.builder(service)
|
||||||
|
.setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/")
|
||||||
|
.createInitializationOptions());
|
||||||
|
} catch (final UnsatisfiedLinkError e) {
|
||||||
|
throw new InitializationException("Unable to initialize PeerConnectionFactory", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.eglBase = EglBase.create();
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw new InitializationException("Unable to create EGL base", e);
|
||||||
|
}
|
||||||
|
this.context = service;
|
||||||
|
mainHandler.post(
|
||||||
|
() -> {
|
||||||
|
appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
|
||||||
|
ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(true);
|
||||||
|
appRTCAudioManager.start(audioManagerEvents);
|
||||||
|
eventCallback.onAudioDeviceChanged(
|
||||||
|
appRTCAudioManager.getSelectedAudioDevice(),
|
||||||
|
appRTCAudioManager.getAudioDevices());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void initializePeerConnection(
|
||||||
|
final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
|
||||||
|
throws InitializationException {
|
||||||
|
Preconditions.checkState(this.eglBase != null);
|
||||||
|
Preconditions.checkNotNull(media);
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
media.size() > 0, "media can not be empty when initializing peer connection");
|
||||||
|
final boolean setUseHardwareAcousticEchoCanceler =
|
||||||
|
WebRtcAudioEffects.canUseAcousticEchoCanceler()
|
||||||
|
&& !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
String.format(
|
||||||
|
"setUseHardwareAcousticEchoCanceler(%s) model=%s",
|
||||||
|
setUseHardwareAcousticEchoCanceler, Build.MODEL));
|
||||||
|
this.peerConnectionFactory =
|
||||||
|
PeerConnectionFactory.builder()
|
||||||
|
.setVideoDecoderFactory(
|
||||||
|
new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
|
||||||
|
.setVideoEncoderFactory(
|
||||||
|
new DefaultVideoEncoderFactory(
|
||||||
|
eglBase.getEglBaseContext(), true, true))
|
||||||
|
.setAudioDeviceModule(
|
||||||
|
JavaAudioDeviceModule.builder(requireContext())
|
||||||
|
.setUseHardwareAcousticEchoCanceler(
|
||||||
|
setUseHardwareAcousticEchoCanceler)
|
||||||
|
.createAudioDeviceModule())
|
||||||
|
.createPeerConnectionFactory();
|
||||||
|
|
||||||
|
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers);
|
||||||
|
final PeerConnection peerConnection =
|
||||||
|
requirePeerConnectionFactory()
|
||||||
|
.createPeerConnection(rtcConfig, peerConnectionObserver);
|
||||||
|
if (peerConnection == null) {
|
||||||
|
throw new InitializationException("Unable to create PeerConnection");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.contains(Media.VIDEO)) {
|
||||||
|
addVideoTrack(peerConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.contains(Media.AUDIO)) {
|
||||||
|
addAudioTrack(peerConnection);
|
||||||
|
}
|
||||||
|
peerConnection.setAudioPlayout(true);
|
||||||
|
peerConnection.setAudioRecording(true);
|
||||||
|
|
||||||
|
this.peerConnection = peerConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VideoSourceWrapper initializeVideoSourceWrapper() {
|
||||||
|
final VideoSourceWrapper existingVideoSourceWrapper = this.videoSourceWrapper;
|
||||||
|
if (existingVideoSourceWrapper != null) {
|
||||||
|
existingVideoSourceWrapper.startCapture();
|
||||||
|
return existingVideoSourceWrapper;
|
||||||
|
}
|
||||||
|
final VideoSourceWrapper videoSourceWrapper =
|
||||||
|
new VideoSourceWrapper.Factory(requireContext()).create();
|
||||||
|
if (videoSourceWrapper == null) {
|
||||||
|
throw new IllegalStateException("Could not instantiate VideoSourceWrapper");
|
||||||
|
}
|
||||||
|
videoSourceWrapper.initialize(
|
||||||
|
requirePeerConnectionFactory(), requireContext(), eglBase.getEglBaseContext());
|
||||||
|
videoSourceWrapper.startCapture();
|
||||||
|
this.videoSourceWrapper = videoSourceWrapper;
|
||||||
|
return videoSourceWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean addTrack(final Media media) {
|
||||||
|
if (media == Media.VIDEO) {
|
||||||
|
return addVideoTrack(requirePeerConnection());
|
||||||
|
} else if (media == Media.AUDIO) {
|
||||||
|
return addAudioTrack(requirePeerConnection());
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(String.format("Could not add track for %s", media));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void removeTrack(final Media media) {
|
||||||
|
if (media == Media.VIDEO) {
|
||||||
|
removeVideoTrack(requirePeerConnection());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean addAudioTrack(final PeerConnection peerConnection) {
|
||||||
|
final AudioSource audioSource =
|
||||||
|
requirePeerConnectionFactory().createAudioSource(new MediaConstraints());
|
||||||
|
final AudioTrack audioTrack =
|
||||||
|
requirePeerConnectionFactory()
|
||||||
|
.createAudioTrack(TrackWrapper.id(AudioTrack.class), audioSource);
|
||||||
|
this.localAudioTrack = TrackWrapper.addTrack(peerConnection, audioTrack);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean addVideoTrack(final PeerConnection peerConnection) {
|
||||||
|
final TrackWrapper<VideoTrack> existing = this.localVideoTrack;
|
||||||
|
if (existing != null) {
|
||||||
|
final RtpTransceiver transceiver =
|
||||||
|
TrackWrapper.getTransceiver(peerConnection, existing);
|
||||||
|
if (transceiver == null) {
|
||||||
|
Log.w(EXTENDED_LOGGING_TAG, "unable to restart video transceiver");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_RECV);
|
||||||
|
this.videoSourceWrapper.startCapture();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final VideoSourceWrapper videoSourceWrapper;
|
||||||
|
try {
|
||||||
|
videoSourceWrapper = initializeVideoSourceWrapper();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
Log.d(Config.LOGTAG, "could not add video track", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final VideoTrack videoTrack =
|
||||||
|
requirePeerConnectionFactory()
|
||||||
|
.createVideoTrack(
|
||||||
|
TrackWrapper.id(VideoTrack.class),
|
||||||
|
videoSourceWrapper.getVideoSource());
|
||||||
|
this.localVideoTrack = TrackWrapper.addTrack(peerConnection, videoTrack);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeVideoTrack(final PeerConnection peerConnection) {
|
||||||
|
final TrackWrapper<VideoTrack> localVideoTrack = this.localVideoTrack;
|
||||||
|
if (localVideoTrack != null) {
|
||||||
|
|
||||||
|
final RtpTransceiver exactTransceiver =
|
||||||
|
TrackWrapper.getTransceiver(peerConnection, localVideoTrack);
|
||||||
|
if (exactTransceiver == null) {
|
||||||
|
throw new IllegalStateException();
|
||||||
|
}
|
||||||
|
exactTransceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.INACTIVE);
|
||||||
|
}
|
||||||
|
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||||
|
if (videoSourceWrapper != null) {
|
||||||
|
try {
|
||||||
|
videoSourceWrapper.stopCapture();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PeerConnection.RTCConfiguration buildConfiguration(
|
||||||
|
final List<PeerConnection.IceServer> iceServers) {
|
||||||
|
final PeerConnection.RTCConfiguration rtcConfig =
|
||||||
|
new PeerConnection.RTCConfiguration(iceServers);
|
||||||
|
rtcConfig.tcpCandidatePolicy =
|
||||||
|
PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
|
||||||
|
rtcConfig.continualGatheringPolicy =
|
||||||
|
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
|
||||||
|
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
|
||||||
|
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
|
||||||
|
rtcConfig.enableImplicitRollback = true;
|
||||||
|
return rtcConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reconfigurePeerConnection(final List<PeerConnection.IceServer> iceServers) {
|
||||||
|
requirePeerConnection().setConfiguration(buildConfiguration(iceServers));
|
||||||
|
}
|
||||||
|
|
||||||
|
void restartIce() {
|
||||||
|
executorService.execute(
|
||||||
|
() -> {
|
||||||
|
final PeerConnection peerConnection;
|
||||||
|
try {
|
||||||
|
peerConnection = requirePeerConnection();
|
||||||
|
} catch (final PeerConnectionNotInitialized e) {
|
||||||
|
Log.w(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"PeerConnection vanished before we could execute restart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsReadyToReceiveIceCandidates(false);
|
||||||
|
peerConnection.restartIce();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsReadyToReceiveIceCandidates(final boolean ready) {
|
||||||
|
readyToReceivedIceCandidates.set(ready);
|
||||||
|
final int was = iceCandidates.size();
|
||||||
|
while (ready && iceCandidates.peek() != null) {
|
||||||
|
eventCallback.onIceCandidate(iceCandidates.poll());
|
||||||
|
}
|
||||||
|
final int is = iceCandidates.size();
|
||||||
|
Log.d(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"setIsReadyToReceiveCandidates(" + ready + ") was=" + was + " is=" + is);
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void close() {
|
||||||
|
final PeerConnection peerConnection = this.peerConnection;
|
||||||
|
final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
|
||||||
|
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||||
|
final AppRTCAudioManager audioManager = this.appRTCAudioManager;
|
||||||
|
final EglBase eglBase = this.eglBase;
|
||||||
|
if (peerConnection != null) {
|
||||||
|
this.peerConnection = null;
|
||||||
|
dispose(peerConnection);
|
||||||
|
}
|
||||||
|
if (audioManager != null) {
|
||||||
|
ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(false);
|
||||||
|
mainHandler.post(audioManager::stop);
|
||||||
|
}
|
||||||
|
this.localVideoTrack = null;
|
||||||
|
this.remoteVideoTrack = null;
|
||||||
|
if (videoSourceWrapper != null) {
|
||||||
|
this.videoSourceWrapper = null;
|
||||||
|
try {
|
||||||
|
videoSourceWrapper.stopCapture();
|
||||||
|
} catch (final InterruptedException e) {
|
||||||
|
Log.e(Config.LOGTAG, "unable to stop capturing");
|
||||||
|
}
|
||||||
|
videoSourceWrapper.dispose();
|
||||||
|
}
|
||||||
|
if (eglBase != null) {
|
||||||
|
eglBase.release();
|
||||||
|
this.eglBase = null;
|
||||||
|
}
|
||||||
|
if (peerConnectionFactory != null) {
|
||||||
|
this.peerConnectionFactory = null;
|
||||||
|
peerConnectionFactory.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void verifyClosed() {
|
||||||
|
if (this.peerConnection != null
|
||||||
|
|| this.eglBase != null
|
||||||
|
|| this.localVideoTrack != null
|
||||||
|
|| this.remoteVideoTrack != null) {
|
||||||
|
final IllegalStateException e =
|
||||||
|
new IllegalStateException("WebRTCWrapper hasn't been closed properly");
|
||||||
|
Log.e(Config.LOGTAG, "verifyClosed() failed. Going to throw", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isCameraSwitchable() {
|
||||||
|
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||||
|
return videoSourceWrapper != null && videoSourceWrapper.isCameraSwitchable();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isFrontCamera() {
|
||||||
|
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||||
|
return videoSourceWrapper == null || videoSourceWrapper.isFrontCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
ListenableFuture<Boolean> switchCamera() {
|
||||||
|
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||||
|
if (videoSourceWrapper == null) {
|
||||||
|
return Futures.immediateFailedFuture(
|
||||||
|
new IllegalStateException("VideoSourceWrapper has not been initialized"));
|
||||||
|
}
|
||||||
|
return videoSourceWrapper.switchCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isMicrophoneEnabled() {
|
||||||
|
final Optional<AudioTrack> audioTrack =
|
||||||
|
TrackWrapper.get(peerConnection, this.localAudioTrack);
|
||||||
|
if (audioTrack.isPresent()) {
|
||||||
|
try {
|
||||||
|
return audioTrack.get().enabled();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
// sometimes UI might still be rendering the buttons when a background thread has
|
||||||
|
// already ended the call
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException("Local audio track does not exist (yet)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean setMicrophoneEnabled(final boolean enabled) {
|
||||||
|
final Optional<AudioTrack> audioTrack =
|
||||||
|
TrackWrapper.get(peerConnection, this.localAudioTrack);
|
||||||
|
if (audioTrack.isPresent()) {
|
||||||
|
try {
|
||||||
|
audioTrack.get().setEnabled(enabled);
|
||||||
|
return true;
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
Log.d(Config.LOGTAG, "unable to toggle microphone", e);
|
||||||
|
// ignoring race condition in case MediaStreamTrack has been disposed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException("Local audio track does not exist (yet)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isVideoEnabled() {
|
||||||
|
final Optional<VideoTrack> videoTrack =
|
||||||
|
TrackWrapper.get(peerConnection, this.localVideoTrack);
|
||||||
|
if (videoTrack.isPresent()) {
|
||||||
|
return videoTrack.get().enabled();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setVideoEnabled(final boolean enabled) {
|
||||||
|
final Optional<VideoTrack> videoTrack =
|
||||||
|
TrackWrapper.get(peerConnection, this.localVideoTrack);
|
||||||
|
if (videoTrack.isPresent()) {
|
||||||
|
videoTrack.get().setEnabled(enabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Local video track does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized ListenableFuture<SessionDescription> setLocalDescription() {
|
||||||
|
return Futures.transformAsync(
|
||||||
|
getPeerConnectionFuture(),
|
||||||
|
peerConnection -> {
|
||||||
|
if (peerConnection == null) {
|
||||||
|
return Futures.immediateFailedFuture(
|
||||||
|
new IllegalStateException("PeerConnection was null"));
|
||||||
|
}
|
||||||
|
final SettableFuture<SessionDescription> future = SettableFuture.create();
|
||||||
|
peerConnection.setLocalDescription(
|
||||||
|
new SetSdpObserver() {
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {
|
||||||
|
final SessionDescription description =
|
||||||
|
peerConnection.getLocalDescription();
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, "set local description:");
|
||||||
|
logDescription(description);
|
||||||
|
future.set(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSetFailure(final String message) {
|
||||||
|
future.setException(
|
||||||
|
new FailureToSetDescriptionException(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return future;
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void logDescription(final SessionDescription sessionDescription) {
|
||||||
|
for (final String line :
|
||||||
|
sessionDescription.description.split(
|
||||||
|
eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized ListenableFuture<Void> setRemoteDescription(
|
||||||
|
final SessionDescription sessionDescription) {
|
||||||
|
Log.d(EXTENDED_LOGGING_TAG, "setting remote description:");
|
||||||
|
logDescription(sessionDescription);
|
||||||
|
return Futures.transformAsync(
|
||||||
|
getPeerConnectionFuture(),
|
||||||
|
peerConnection -> {
|
||||||
|
if (peerConnection == null) {
|
||||||
|
return Futures.immediateFailedFuture(
|
||||||
|
new IllegalStateException("PeerConnection was null"));
|
||||||
|
}
|
||||||
|
final SettableFuture<Void> future = SettableFuture.create();
|
||||||
|
peerConnection.setRemoteDescription(
|
||||||
|
new SetSdpObserver() {
|
||||||
|
@Override
|
||||||
|
public void onSetSuccess() {
|
||||||
|
future.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSetFailure(final String message) {
|
||||||
|
future.setException(
|
||||||
|
new FailureToSetDescriptionException(message));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sessionDescription);
|
||||||
|
return future;
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private ListenableFuture<PeerConnection> getPeerConnectionFuture() {
|
||||||
|
final PeerConnection peerConnection = this.peerConnection;
|
||||||
|
if (peerConnection == null) {
|
||||||
|
return Futures.immediateFailedFuture(new PeerConnectionNotInitialized());
|
||||||
|
} else {
|
||||||
|
return Futures.immediateFuture(peerConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private PeerConnection requirePeerConnection() {
|
||||||
|
final PeerConnection peerConnection = this.peerConnection;
|
||||||
|
if (peerConnection == null) {
|
||||||
|
throw new PeerConnectionNotInitialized();
|
||||||
|
}
|
||||||
|
return peerConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private PeerConnectionFactory requirePeerConnectionFactory() {
|
||||||
|
final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
|
||||||
|
if (peerConnectionFactory == null) {
|
||||||
|
throw new IllegalStateException("Make sure PeerConnectionFactory is initialized");
|
||||||
|
}
|
||||||
|
return peerConnectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
void addIceCandidate(IceCandidate iceCandidate) {
|
||||||
|
requirePeerConnection().addIceCandidate(iceCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerConnection.PeerConnectionState getState() {
|
||||||
|
return requirePeerConnection().connectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PeerConnection.SignalingState getSignalingState() {
|
||||||
|
try {
|
||||||
|
return requirePeerConnection().signalingState();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
return PeerConnection.SignalingState.CLOSED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EglBase.Context getEglBaseContext() {
|
||||||
|
return this.eglBase.getEglBaseContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<VideoTrack> getLocalVideoTrack() {
|
||||||
|
return TrackWrapper.get(peerConnection, this.localVideoTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<VideoTrack> getRemoteVideoTrack() {
|
||||||
|
return Optional.fromNullable(this.remoteVideoTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Context requireContext() {
|
||||||
|
final Context context = this.context;
|
||||||
|
if (context == null) {
|
||||||
|
throw new IllegalStateException("call setup first");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppRTCAudioManager getAudioManager() {
|
||||||
|
return appRTCAudioManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
void execute(final Runnable command) {
|
||||||
|
executorService.execute(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
|
||||||
|
mainHandler.post(() -> appRTCAudioManager.switchSpeakerPhonePreference(preference));
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface EventCallback {
|
||||||
|
void onIceCandidate(IceCandidate iceCandidate);
|
||||||
|
|
||||||
|
void onConnectionChange(PeerConnection.PeerConnectionState newState);
|
||||||
|
|
||||||
|
void onAudioDeviceChanged(
|
||||||
|
AppRTCAudioManager.AudioDevice selectedAudioDevice,
|
||||||
|
Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
|
||||||
|
|
||||||
|
void onRenegotiationNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private abstract static class SetSdpObserver implements SdpObserver {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateSuccess(org.webrtc.SessionDescription sessionDescription) {
|
||||||
|
throw new IllegalStateException("Not able to use SetSdpObserver");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateFailure(String s) {
|
||||||
|
throw new IllegalStateException("Not able to use SetSdpObserver");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class InitializationException extends Exception {
|
||||||
|
|
||||||
|
private InitializationException(final String message, final Throwable throwable) {
|
||||||
|
super(message, throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InitializationException(final String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PeerConnectionNotInitialized extends IllegalStateException {
|
||||||
|
|
||||||
|
private PeerConnectionNotInitialized() {
|
||||||
|
super("initialize PeerConnection first");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FailureToSetDescriptionException extends IllegalArgumentException {
|
||||||
|
public FailureToSetDescriptionException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class Content extends Element {
|
||||||
|
|
||||||
|
public Content(final Creator creator, final Senders senders, final String name) {
|
||||||
|
super("content", Namespace.JINGLE);
|
||||||
|
this.setAttribute("creator", creator.toString());
|
||||||
|
this.setAttribute("name", name);
|
||||||
|
this.setSenders(senders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Content() {
|
||||||
|
super("content", Namespace.JINGLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Content upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("content".equals(element.getName()));
|
||||||
|
final Content content = new Content();
|
||||||
|
content.setAttributes(element.getAttributes());
|
||||||
|
content.setChildren(element.getChildren());
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContentName() {
|
||||||
|
return this.getAttribute("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Creator getCreator() {
|
||||||
|
return Creator.of(getAttribute("creator"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Senders getSenders() {
|
||||||
|
final String attribute = getAttribute("senders");
|
||||||
|
if (Strings.isNullOrEmpty(attribute)) {
|
||||||
|
return Senders.BOTH;
|
||||||
|
}
|
||||||
|
return Senders.of(getAttribute("senders"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenders(final Senders senders) {
|
||||||
|
if (senders != null && senders != Senders.BOTH) {
|
||||||
|
this.setAttribute("senders", senders.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public GenericDescription getDescription() {
|
||||||
|
final Element description = this.findChild("description");
|
||||||
|
if (description == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String namespace = description.getNamespace();
|
||||||
|
if (FileTransferDescription.NAMESPACES.contains(namespace)) {
|
||||||
|
return FileTransferDescription.upgrade(description);
|
||||||
|
} else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
|
||||||
|
return RtpDescription.upgrade(description);
|
||||||
|
} else {
|
||||||
|
return GenericDescription.upgrade(description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(final GenericDescription description) {
|
||||||
|
Preconditions.checkNotNull(description);
|
||||||
|
this.addChild(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionNamespace() {
|
||||||
|
final Element description = this.findChild("description");
|
||||||
|
return description == null ? null : description.getNamespace();
|
||||||
|
}
|
||||||
|
|
||||||
|
public GenericTransportInfo getTransport() {
|
||||||
|
final Element transport = this.findChild("transport");
|
||||||
|
final String namespace = transport == null ? null : transport.getNamespace();
|
||||||
|
if (Namespace.JINGLE_TRANSPORTS_IBB.equals(namespace)) {
|
||||||
|
return IbbTransportInfo.upgrade(transport);
|
||||||
|
} else if (Namespace.JINGLE_TRANSPORTS_S5B.equals(namespace)) {
|
||||||
|
return S5BTransportInfo.upgrade(transport);
|
||||||
|
} else if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) {
|
||||||
|
return IceUdpTransportInfo.upgrade(transport);
|
||||||
|
} else if (transport != null) {
|
||||||
|
return GenericTransportInfo.upgrade(transport);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTransport(GenericTransportInfo transportInfo) {
|
||||||
|
this.addChild(transportInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Creator {
|
||||||
|
INITIATOR,
|
||||||
|
RESPONDER;
|
||||||
|
|
||||||
|
public static Creator of(final String value) {
|
||||||
|
return Creator.valueOf(value.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String toString() {
|
||||||
|
return super.toString().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Senders {
|
||||||
|
BOTH,
|
||||||
|
INITIATOR,
|
||||||
|
NONE,
|
||||||
|
RESPONDER;
|
||||||
|
|
||||||
|
public static Senders of(final String value) {
|
||||||
|
return Senders.valueOf(value.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Senders of(final SessionDescription.Media media, final boolean initiator) {
|
||||||
|
final Set<String> attributes = media.attributes.keySet();
|
||||||
|
if (attributes.contains("sendrecv")) {
|
||||||
|
return BOTH;
|
||||||
|
} else if (attributes.contains("inactive")) {
|
||||||
|
return NONE;
|
||||||
|
} else if (attributes.contains("sendonly")) {
|
||||||
|
return initiator ? INITIATOR : RESPONDER;
|
||||||
|
} else if (attributes.contains("recvonly")) {
|
||||||
|
return initiator ? RESPONDER : INITIATOR;
|
||||||
|
}
|
||||||
|
Log.w(Config.LOGTAG, "assuming default value for senders");
|
||||||
|
// If none of the attributes "sendonly", "recvonly", "inactive", and "sendrecv" is
|
||||||
|
// present, "sendrecv" SHOULD be assumed as the default
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc4566
|
||||||
|
return BOTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String toString() {
|
||||||
|
return super.toString().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String asMediaAttribute(final boolean initiator) {
|
||||||
|
final boolean responder = !initiator;
|
||||||
|
if (this == Content.Senders.BOTH) {
|
||||||
|
return "sendrecv";
|
||||||
|
} else if (this == Content.Senders.NONE) {
|
||||||
|
return "inactive";
|
||||||
|
} else if ((initiator && this == Content.Senders.INITIATOR)
|
||||||
|
|| (responder && this == Content.Senders.RESPONDER)) {
|
||||||
|
return "sendonly";
|
||||||
|
} else if ((initiator && this == Content.Senders.RESPONDER)
|
||||||
|
|| (responder && this == Content.Senders.INITIATOR)) {
|
||||||
|
return "recvonly";
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
String.format(
|
||||||
|
"illegal combination of initiator=%s and %s", initiator, this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class FileTransferDescription extends GenericDescription {
|
||||||
|
|
||||||
|
public static List<String> NAMESPACES =
|
||||||
|
Arrays.asList(Version.FT_3.namespace, Version.FT_4.namespace, Version.FT_5.namespace);
|
||||||
|
|
||||||
|
private FileTransferDescription(String name, String namespace) {
|
||||||
|
super(name, namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Version getVersion() {
|
||||||
|
final String namespace = getNamespace();
|
||||||
|
if (namespace.equals(Version.FT_3.namespace)) {
|
||||||
|
return Version.FT_3;
|
||||||
|
} else if (namespace.equals(Version.FT_4.namespace)) {
|
||||||
|
return Version.FT_4;
|
||||||
|
} else if (namespace.equals(Version.FT_5.namespace)) {
|
||||||
|
return Version.FT_5;
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException("Unknown namespace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Element getFileOffer() {
|
||||||
|
final Version version = getVersion();
|
||||||
|
if (version == Version.FT_3) {
|
||||||
|
final Element offer = this.findChild("offer");
|
||||||
|
return offer == null ? null : offer.findChild("file");
|
||||||
|
} else {
|
||||||
|
return this.findChild("file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FileTransferDescription upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"description".equals(element.getName()),
|
||||||
|
"Name of provided element is not description");
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
NAMESPACES.contains(element.getNamespace()),
|
||||||
|
"Element does not match a file transfer namespace");
|
||||||
|
final FileTransferDescription description =
|
||||||
|
new FileTransferDescription("description", element.getNamespace());
|
||||||
|
description.setAttributes(element.getAttributes());
|
||||||
|
description.setChildren(element.getChildren());
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Version {
|
||||||
|
FT_3("urn:xmpp:jingle:apps:file-transfer:3"),
|
||||||
|
FT_4("urn:xmpp:jingle:apps:file-transfer:4"),
|
||||||
|
FT_5("urn:xmpp:jingle:apps:file-transfer:5");
|
||||||
|
|
||||||
|
private final String namespace;
|
||||||
|
|
||||||
|
Version(String namespace) {
|
||||||
|
this.namespace = namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNamespace() {
|
||||||
|
return namespace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
|
||||||
|
public class GenericDescription extends Element {
|
||||||
|
|
||||||
|
GenericDescription(String name, final String namespace) {
|
||||||
|
super(name, namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GenericDescription upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("description".equals(element.getName()));
|
||||||
|
final GenericDescription description =
|
||||||
|
new GenericDescription("description", element.getNamespace());
|
||||||
|
description.setAttributes(element.getAttributes());
|
||||||
|
description.setChildren(element.getChildren());
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
|
||||||
|
public class GenericTransportInfo extends Element {
|
||||||
|
|
||||||
|
protected GenericTransportInfo(String name, String xmlns) {
|
||||||
|
super(name, xmlns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GenericTransportInfo upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("transport".equals(element.getName()));
|
||||||
|
final GenericTransportInfo transport =
|
||||||
|
new GenericTransportInfo("transport", element.getNamespace());
|
||||||
|
transport.setAttributes(element.getAttributes());
|
||||||
|
transport.setChildren(element.getChildren());
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Group extends Element {
|
||||||
|
|
||||||
|
private Group() {
|
||||||
|
super("group", Namespace.JINGLE_APPS_GROUPING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Group(final String semantics, final Collection<String> identificationTags) {
|
||||||
|
super("group", Namespace.JINGLE_APPS_GROUPING);
|
||||||
|
this.setAttribute("semantics", semantics);
|
||||||
|
for (String tag : identificationTags) {
|
||||||
|
this.addChild(new Element("content").setAttribute("name", tag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSemantics() {
|
||||||
|
return this.getAttribute("semantics");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getIdentificationTags() {
|
||||||
|
final ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : this.children) {
|
||||||
|
if ("content".equals(child.getName())) {
|
||||||
|
final String name = child.getAttribute("name");
|
||||||
|
if (name != null) {
|
||||||
|
builder.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Group ofSdpString(final String input) {
|
||||||
|
ImmutableList.Builder<String> tagBuilder = new ImmutableList.Builder<>();
|
||||||
|
final String[] parts = input.split(" ");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final String semantics = parts[0];
|
||||||
|
for (int i = 1; i < parts.length; ++i) {
|
||||||
|
tagBuilder.add(parts[i]);
|
||||||
|
}
|
||||||
|
return new Group(semantics, tagBuilder.build());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Group upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("group".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(Namespace.JINGLE_APPS_GROUPING.equals(element.getNamespace()));
|
||||||
|
final Group group = new Group();
|
||||||
|
group.setAttributes(element.getAttributes());
|
||||||
|
group.setChildren(element.getChildren());
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
|
||||||
|
public class IbbTransportInfo extends GenericTransportInfo {
|
||||||
|
|
||||||
|
private IbbTransportInfo(final String name, final String xmlns) {
|
||||||
|
super(name, xmlns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IbbTransportInfo(final String transportId, final int blockSize) {
|
||||||
|
super("transport", Namespace.JINGLE_TRANSPORTS_IBB);
|
||||||
|
Preconditions.checkNotNull(transportId, "Transport ID can not be null");
|
||||||
|
Preconditions.checkArgument(blockSize > 0, "Block size must be larger than 0");
|
||||||
|
this.setAttribute("block-size", blockSize);
|
||||||
|
this.setAttribute("sid", transportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTransportId() {
|
||||||
|
return this.getAttribute("sid");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBlockSize() {
|
||||||
|
final String blockSize = this.getAttribute("block-size");
|
||||||
|
if (blockSize == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(blockSize);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IbbTransportInfo upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"transport".equals(element.getName()), "Name of provided element is not transport");
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_TRANSPORTS_IBB.equals(element.getNamespace()),
|
||||||
|
"Element does not match ibb transport namespace");
|
||||||
|
final IbbTransportInfo transportInfo =
|
||||||
|
new IbbTransportInfo("transport", Namespace.JINGLE_TRANSPORTS_IBB);
|
||||||
|
transportInfo.setAttributes(element.getAttributes());
|
||||||
|
transportInfo.setChildren(element.getChildren());
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,411 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import com.google.common.base.Joiner;
|
||||||
|
import com.google.common.base.MoreObjects;
|
||||||
|
import com.google.common.base.Objects;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
import com.google.common.collect.Collections2;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Hashtable;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class IceUdpTransportInfo extends GenericTransportInfo {
|
||||||
|
|
||||||
|
public static final IceUdpTransportInfo STUB = new IceUdpTransportInfo();
|
||||||
|
|
||||||
|
public IceUdpTransportInfo() {
|
||||||
|
super("transport", Namespace.JINGLE_TRANSPORT_ICE_UDP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IceUdpTransportInfo upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"transport".equals(element.getName()), "Name of provided element is not transport");
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(element.getNamespace()),
|
||||||
|
"Element does not match ice-udp transport namespace");
|
||||||
|
final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo();
|
||||||
|
transportInfo.setAttributes(element.getAttributes());
|
||||||
|
transportInfo.setChildren(element.getChildren());
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IceUdpTransportInfo of(
|
||||||
|
SessionDescription sessionDescription, SessionDescription.Media media) {
|
||||||
|
final String ufrag = Iterables.getFirst(media.attributes.get("ice-ufrag"), null);
|
||||||
|
final String pwd = Iterables.getFirst(media.attributes.get("ice-pwd"), null);
|
||||||
|
final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
|
||||||
|
if (ufrag != null) {
|
||||||
|
iceUdpTransportInfo.setAttribute("ufrag", ufrag);
|
||||||
|
}
|
||||||
|
if (pwd != null) {
|
||||||
|
iceUdpTransportInfo.setAttribute("pwd", pwd);
|
||||||
|
}
|
||||||
|
final Fingerprint fingerprint = Fingerprint.of(sessionDescription, media);
|
||||||
|
if (fingerprint != null) {
|
||||||
|
iceUdpTransportInfo.addChild(fingerprint);
|
||||||
|
}
|
||||||
|
return iceUdpTransportInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IceUdpTransportInfo of(
|
||||||
|
final Credentials credentials,
|
||||||
|
final Setup setup,
|
||||||
|
final String hash,
|
||||||
|
final String fingerprint) {
|
||||||
|
final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
|
||||||
|
iceUdpTransportInfo.addChild(Fingerprint.of(setup, hash, fingerprint));
|
||||||
|
iceUdpTransportInfo.setAttribute("ufrag", credentials.ufrag);
|
||||||
|
iceUdpTransportInfo.setAttribute("pwd", credentials.password);
|
||||||
|
return iceUdpTransportInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fingerprint getFingerprint() {
|
||||||
|
final Element fingerprint = this.findChild("fingerprint", Namespace.JINGLE_APPS_DTLS);
|
||||||
|
return fingerprint == null ? null : Fingerprint.upgrade(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Credentials getCredentials() {
|
||||||
|
final String ufrag = this.getAttribute("ufrag");
|
||||||
|
final String password = this.getAttribute("pwd");
|
||||||
|
return new Credentials(ufrag, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Candidate> getCandidates() {
|
||||||
|
final ImmutableList.Builder<Candidate> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : getChildren()) {
|
||||||
|
if ("candidate".equals(child.getName())) {
|
||||||
|
builder.add(Candidate.upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IceUdpTransportInfo cloneWrapper() {
|
||||||
|
final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo();
|
||||||
|
transportInfo.setAttributes(new Hashtable<>(getAttributes()));
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IceUdpTransportInfo modifyCredentials(final Credentials credentials, final Setup setup) {
|
||||||
|
final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo();
|
||||||
|
transportInfo.setAttribute("ufrag", credentials.ufrag);
|
||||||
|
transportInfo.setAttribute("pwd", credentials.password);
|
||||||
|
for (final Element child : getChildren()) {
|
||||||
|
if (child.getName().equals("fingerprint")
|
||||||
|
&& Namespace.JINGLE_APPS_DTLS.equals(child.getNamespace())) {
|
||||||
|
final Fingerprint fingerprint = new Fingerprint();
|
||||||
|
fingerprint.setAttributes(new Hashtable<>(child.getAttributes()));
|
||||||
|
fingerprint.setContent(child.getContent());
|
||||||
|
fingerprint.setAttribute("setup", setup.toString().toLowerCase(Locale.ROOT));
|
||||||
|
transportInfo.addChild(fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Credentials {
|
||||||
|
public final String ufrag;
|
||||||
|
public final String password;
|
||||||
|
|
||||||
|
public Credentials(String ufrag, String password) {
|
||||||
|
this.ufrag = ufrag;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
Credentials that = (Credentials) o;
|
||||||
|
return Objects.equal(ufrag, that.ufrag) && Objects.equal(password, that.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(ufrag, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String toString() {
|
||||||
|
return MoreObjects.toStringHelper(this)
|
||||||
|
.add("ufrag", ufrag)
|
||||||
|
.add("password", password)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Candidate extends Element {
|
||||||
|
|
||||||
|
private Candidate() {
|
||||||
|
super("candidate");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Candidate upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("candidate".equals(element.getName()));
|
||||||
|
final Candidate candidate = new Candidate();
|
||||||
|
candidate.setAttributes(element.getAttributes());
|
||||||
|
candidate.setChildren(element.getChildren());
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-mmusic-ice-sip-sdp-39#section-5.1
|
||||||
|
public static Candidate fromSdpAttribute(final String attribute, String currentUfrag) {
|
||||||
|
final String[] pair = attribute.split(":", 2);
|
||||||
|
if (pair.length == 2 && "candidate".equals(pair[0])) {
|
||||||
|
final String[] segments = pair[1].split(" ");
|
||||||
|
if (segments.length >= 6) {
|
||||||
|
final String id = UUID.randomUUID().toString();
|
||||||
|
final String foundation = segments[0];
|
||||||
|
final String component = segments[1];
|
||||||
|
final String transport = segments[2].toLowerCase(Locale.ROOT);
|
||||||
|
final String priority = segments[3];
|
||||||
|
final String connectionAddress = segments[4];
|
||||||
|
final String port = segments[5];
|
||||||
|
final HashMap<String, String> additional = new HashMap<>();
|
||||||
|
for (int i = 6; i < segments.length - 1; i = i + 2) {
|
||||||
|
additional.put(segments[i], segments[i + 1]);
|
||||||
|
}
|
||||||
|
final String ufrag = additional.get("ufrag");
|
||||||
|
if (ufrag != null && !ufrag.equals(currentUfrag)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Candidate candidate = new Candidate();
|
||||||
|
candidate.setAttribute("component", component);
|
||||||
|
candidate.setAttribute("foundation", foundation);
|
||||||
|
candidate.setAttribute("generation", additional.get("generation"));
|
||||||
|
candidate.setAttribute("rel-addr", additional.get("raddr"));
|
||||||
|
candidate.setAttribute("rel-port", additional.get("rport"));
|
||||||
|
candidate.setAttribute("id", id);
|
||||||
|
candidate.setAttribute("ip", connectionAddress);
|
||||||
|
candidate.setAttribute("port", port);
|
||||||
|
candidate.setAttribute("priority", priority);
|
||||||
|
candidate.setAttribute("protocol", transport);
|
||||||
|
candidate.setAttribute("type", additional.get("typ"));
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getComponent() {
|
||||||
|
return getAttributeAsInt("component");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getFoundation() {
|
||||||
|
return getAttributeAsInt("foundation");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getGeneration() {
|
||||||
|
return getAttributeAsInt("generation");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return getAttribute("id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIp() {
|
||||||
|
return getAttribute("ip");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNetwork() {
|
||||||
|
return getAttributeAsInt("network");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPort() {
|
||||||
|
return getAttributeAsInt("port");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPriority() {
|
||||||
|
return getAttributeAsInt("priority");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProtocol() {
|
||||||
|
return getAttribute("protocol");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRelAddr() {
|
||||||
|
return getAttribute("rel-addr");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRelPort() {
|
||||||
|
return getAttributeAsInt("rel-port");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() { // TODO might be converted to enum
|
||||||
|
return getAttribute("type");
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getAttributeAsInt(final String name) {
|
||||||
|
final String value = this.getAttribute(name);
|
||||||
|
if (value == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toSdpAttribute(final String ufrag) {
|
||||||
|
final String foundation = this.getAttribute("foundation");
|
||||||
|
checkNotNullNoWhitespace(foundation, "foundation");
|
||||||
|
final String component = this.getAttribute("component");
|
||||||
|
checkNotNullNoWhitespace(component, "component");
|
||||||
|
final String protocol = this.getAttribute("protocol");
|
||||||
|
checkNotNullNoWhitespace(protocol, "protocol");
|
||||||
|
final String transport = protocol.toLowerCase(Locale.ROOT);
|
||||||
|
if (!"udp".equals(transport)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
String.format("'%s' is not a supported protocol", transport));
|
||||||
|
}
|
||||||
|
final String priority = this.getAttribute("priority");
|
||||||
|
checkNotNullNoWhitespace(priority, "priority");
|
||||||
|
final String connectionAddress = this.getAttribute("ip");
|
||||||
|
checkNotNullNoWhitespace(connectionAddress, "ip");
|
||||||
|
final String port = this.getAttribute("port");
|
||||||
|
checkNotNullNoWhitespace(port, "port");
|
||||||
|
final Map<String, String> additionalParameter = new LinkedHashMap<>();
|
||||||
|
final String relAddr = this.getAttribute("rel-addr");
|
||||||
|
final String type = this.getAttribute("type");
|
||||||
|
if (type != null) {
|
||||||
|
additionalParameter.put("typ", type);
|
||||||
|
}
|
||||||
|
if (relAddr != null) {
|
||||||
|
additionalParameter.put("raddr", relAddr);
|
||||||
|
}
|
||||||
|
final String relPort = this.getAttribute("rel-port");
|
||||||
|
if (relPort != null) {
|
||||||
|
additionalParameter.put("rport", relPort);
|
||||||
|
}
|
||||||
|
final String generation = this.getAttribute("generation");
|
||||||
|
if (generation != null) {
|
||||||
|
additionalParameter.put("generation", generation);
|
||||||
|
}
|
||||||
|
if (ufrag != null) {
|
||||||
|
additionalParameter.put("ufrag", ufrag);
|
||||||
|
}
|
||||||
|
final String parametersString =
|
||||||
|
Joiner.on(' ')
|
||||||
|
.join(
|
||||||
|
Collections2.transform(
|
||||||
|
additionalParameter.entrySet(),
|
||||||
|
input ->
|
||||||
|
String.format(
|
||||||
|
"%s %s",
|
||||||
|
input.getKey(), input.getValue())));
|
||||||
|
return String.format(
|
||||||
|
"candidate:%s %s %s %s %s %s %s",
|
||||||
|
foundation,
|
||||||
|
component,
|
||||||
|
transport,
|
||||||
|
priority,
|
||||||
|
connectionAddress,
|
||||||
|
port,
|
||||||
|
parametersString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void checkNotNullNoWhitespace(final String value, final String name) {
|
||||||
|
if (Strings.isNullOrEmpty(value)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
String.format("Parameter %s is missing or empty", name));
|
||||||
|
}
|
||||||
|
SessionDescription.checkNoWhitespace(
|
||||||
|
value, String.format("Parameter %s contains white spaces", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Fingerprint extends Element {
|
||||||
|
|
||||||
|
private Fingerprint() {
|
||||||
|
super("fingerprint", Namespace.JINGLE_APPS_DTLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fingerprint upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("fingerprint".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(Namespace.JINGLE_APPS_DTLS.equals(element.getNamespace()));
|
||||||
|
final Fingerprint fingerprint = new Fingerprint();
|
||||||
|
fingerprint.setAttributes(element.getAttributes());
|
||||||
|
fingerprint.setContent(element.getContent());
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fingerprint of(ArrayListMultimap<String, String> attributes) {
|
||||||
|
final String fingerprint = Iterables.getFirst(attributes.get("fingerprint"), null);
|
||||||
|
final String setup = Iterables.getFirst(attributes.get("setup"), null);
|
||||||
|
if (setup != null && fingerprint != null) {
|
||||||
|
final String[] fingerprintParts = fingerprint.split(" ", 2);
|
||||||
|
if (fingerprintParts.length == 2) {
|
||||||
|
final String hash = fingerprintParts[0];
|
||||||
|
final String actualFingerprint = fingerprintParts[1];
|
||||||
|
final Fingerprint element = new Fingerprint();
|
||||||
|
element.setAttribute("hash", hash);
|
||||||
|
element.setAttribute("setup", setup);
|
||||||
|
element.setContent(actualFingerprint);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fingerprint of(
|
||||||
|
final SessionDescription sessionDescription, final SessionDescription.Media media) {
|
||||||
|
final Fingerprint fingerprint = of(media.attributes);
|
||||||
|
return fingerprint == null ? of(sessionDescription.attributes) : fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fingerprint of(final Setup setup, final String hash, final String content) {
|
||||||
|
final Fingerprint fingerprint = new Fingerprint();
|
||||||
|
fingerprint.setContent(content);
|
||||||
|
fingerprint.setAttribute("hash", hash);
|
||||||
|
fingerprint.setAttribute("setup", setup.toString().toLowerCase(Locale.ROOT));
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHash() {
|
||||||
|
return this.getAttribute("hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Setup getSetup() {
|
||||||
|
final String setup = this.getAttribute("setup");
|
||||||
|
return setup == null ? null : Setup.of(setup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Setup {
|
||||||
|
ACTPASS,
|
||||||
|
PASSIVE,
|
||||||
|
ACTIVE;
|
||||||
|
|
||||||
|
public static Setup of(String setup) {
|
||||||
|
try {
|
||||||
|
return valueOf(setup.toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Setup flip() {
|
||||||
|
if (this == PASSIVE) {
|
||||||
|
return ACTIVE;
|
||||||
|
}
|
||||||
|
if (this == ACTIVE) {
|
||||||
|
return PASSIVE;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(this.name() + " can not be flipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import com.google.common.base.CaseFormat;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import im.conversations.android.xmpp.model.stanza.Iq;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
|
||||||
|
public class JinglePacket extends Iq {
|
||||||
|
|
||||||
|
private JinglePacket() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public JinglePacket(final Action action, final String sessionId) {
|
||||||
|
super(Iq.Type.SET);
|
||||||
|
final Element jingle = addChild("jingle", Namespace.JINGLE);
|
||||||
|
jingle.setAttribute("sid", sessionId);
|
||||||
|
jingle.setAttribute("action", action.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JinglePacket upgrade(final Iq iqPacket) {
|
||||||
|
Preconditions.checkArgument(iqPacket.hasChild("jingle", Namespace.JINGLE));
|
||||||
|
Preconditions.checkArgument(iqPacket.getType() == Iq.Type.SET);
|
||||||
|
final JinglePacket jinglePacket = new JinglePacket();
|
||||||
|
jinglePacket.setAttributes(iqPacket.getAttributes());
|
||||||
|
jinglePacket.setChildren(iqPacket.getChildren());
|
||||||
|
return jinglePacket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO deprecate this somehow and make file transfer fail if there are multiple (or something)
|
||||||
|
public Content getJingleContent() {
|
||||||
|
final Element content = getJingleChild("content");
|
||||||
|
return content == null ? null : Content.upgrade(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Group getGroup() {
|
||||||
|
final Element jingle = findChild("jingle", Namespace.JINGLE);
|
||||||
|
final Element group = jingle.findChild("group", Namespace.JINGLE_APPS_GROUPING);
|
||||||
|
return group == null ? null : Group.upgrade(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addGroup(final Group group) {
|
||||||
|
this.addJingleChild(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Content> getJingleContents() {
|
||||||
|
final Element jingle = findChild("jingle", Namespace.JINGLE);
|
||||||
|
ImmutableMap.Builder<String, Content> builder = new ImmutableMap.Builder<>();
|
||||||
|
for (final Element child : jingle.getChildren()) {
|
||||||
|
if ("content".equals(child.getName())) {
|
||||||
|
final Content content = Content.upgrade(child);
|
||||||
|
builder.put(content.getContentName(), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addJingleContent(final Content content) { // take content interface
|
||||||
|
addJingleChild(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReasonWrapper getReason() {
|
||||||
|
final Element reasonElement = getJingleChild("reason");
|
||||||
|
if (reasonElement == null) {
|
||||||
|
return new ReasonWrapper(Reason.UNKNOWN, null);
|
||||||
|
}
|
||||||
|
String text = null;
|
||||||
|
Reason reason = Reason.UNKNOWN;
|
||||||
|
for (Element child : reasonElement.getChildren()) {
|
||||||
|
if ("text".equals(child.getName())) {
|
||||||
|
text = child.getContent();
|
||||||
|
} else {
|
||||||
|
reason = Reason.of(child.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ReasonWrapper(reason, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(final Reason reason, final String text) {
|
||||||
|
final Element jingle = findChild("jingle", Namespace.JINGLE);
|
||||||
|
final Element reasonElement = jingle.addChild("reason");
|
||||||
|
reasonElement.addChild(reason.toString());
|
||||||
|
if (!Strings.isNullOrEmpty(text)) {
|
||||||
|
reasonElement.addChild("text").setContent(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise
|
||||||
|
public void setInitiator(final Jid initiator) {
|
||||||
|
Preconditions.checkArgument(initiator.isEntityFullJid(), "initiator should be a full JID");
|
||||||
|
findChild("jingle", Namespace.JINGLE).setAttribute("initiator", initiator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RECOMMENDED for session-accept, NOT RECOMMENDED otherwise
|
||||||
|
public void setResponder(Jid responder) {
|
||||||
|
Preconditions.checkArgument(responder.isEntityFullJid(), "responder should be a full JID");
|
||||||
|
findChild("jingle", Namespace.JINGLE).setAttribute("responder", responder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Element getJingleChild(final String name) {
|
||||||
|
final Element jingle = findChild("jingle", Namespace.JINGLE);
|
||||||
|
return jingle == null ? null : jingle.findChild(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addJingleChild(final Element child) {
|
||||||
|
final Element jingle = findChild("jingle", Namespace.JINGLE);
|
||||||
|
jingle.addChild(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionId() {
|
||||||
|
return findChild("jingle", Namespace.JINGLE).getAttribute("sid");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action getAction() {
|
||||||
|
return Action.of(findChild("jingle", Namespace.JINGLE).getAttribute("action"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Action {
|
||||||
|
CONTENT_ACCEPT,
|
||||||
|
CONTENT_ADD,
|
||||||
|
CONTENT_MODIFY,
|
||||||
|
CONTENT_REJECT,
|
||||||
|
CONTENT_REMOVE,
|
||||||
|
DESCRIPTION_INFO,
|
||||||
|
SECURITY_INFO,
|
||||||
|
SESSION_ACCEPT,
|
||||||
|
SESSION_INFO,
|
||||||
|
SESSION_INITIATE,
|
||||||
|
SESSION_TERMINATE,
|
||||||
|
TRANSPORT_ACCEPT,
|
||||||
|
TRANSPORT_INFO,
|
||||||
|
TRANSPORT_REJECT,
|
||||||
|
TRANSPORT_REPLACE;
|
||||||
|
|
||||||
|
public static Action of(final String value) {
|
||||||
|
// TODO handle invalid
|
||||||
|
return Action.valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String toString() {
|
||||||
|
return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, super.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ReasonWrapper {
|
||||||
|
public final Reason reason;
|
||||||
|
public final String text;
|
||||||
|
|
||||||
|
public ReasonWrapper(Reason reason, String text) {
|
||||||
|
this.reason = reason;
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
|
||||||
|
public class OmemoVerifiedIceUdpTransportInfo extends IceUdpTransportInfo {
|
||||||
|
|
||||||
|
public void ensureNoPlaintextFingerprint() {
|
||||||
|
if (this.findChild("fingerprint", Namespace.JINGLE_APPS_DTLS) != null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"OmemoVerifiedIceUdpTransportInfo contains plaintext fingerprint");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IceUdpTransportInfo upgrade(final IceUdpTransportInfo transportInfo) {
|
||||||
|
if (transportInfo.hasChild("fingerprint", Namespace.JINGLE_APPS_DTLS)) {
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
if (transportInfo.hasChild("fingerprint", Namespace.OMEMO_DTLS_SRTP_VERIFICATION)) {
|
||||||
|
final OmemoVerifiedIceUdpTransportInfo omemoVerifiedIceUdpTransportInfo =
|
||||||
|
new OmemoVerifiedIceUdpTransportInfo();
|
||||||
|
omemoVerifiedIceUdpTransportInfo.setAttributes(transportInfo.getAttributes());
|
||||||
|
omemoVerifiedIceUdpTransportInfo.setChildren(transportInfo.getChildren());
|
||||||
|
return omemoVerifiedIceUdpTransportInfo;
|
||||||
|
}
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import com.google.common.base.CaseFormat;
|
||||||
|
import com.google.common.base.Throwables;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.RtpContentMap;
|
||||||
|
import im.conversations.android.axolotl.AxolotlEncryptionException;
|
||||||
|
|
||||||
|
public enum Reason {
|
||||||
|
ALTERNATIVE_SESSION,
|
||||||
|
BUSY,
|
||||||
|
CANCEL,
|
||||||
|
CONNECTIVITY_ERROR,
|
||||||
|
DECLINE,
|
||||||
|
EXPIRED,
|
||||||
|
FAILED_APPLICATION,
|
||||||
|
FAILED_TRANSPORT,
|
||||||
|
GENERAL_ERROR,
|
||||||
|
GONE,
|
||||||
|
INCOMPATIBLE_PARAMETERS,
|
||||||
|
MEDIA_ERROR,
|
||||||
|
SECURITY_ERROR,
|
||||||
|
SUCCESS,
|
||||||
|
TIMEOUT,
|
||||||
|
UNSUPPORTED_APPLICATIONS,
|
||||||
|
UNSUPPORTED_TRANSPORTS,
|
||||||
|
UNKNOWN;
|
||||||
|
|
||||||
|
public static Reason of(final String value) {
|
||||||
|
try {
|
||||||
|
return Reason.valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String toString() {
|
||||||
|
return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_HYPHEN, super.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Reason of(final RuntimeException e) {
|
||||||
|
if (e instanceof SecurityException) {
|
||||||
|
return SECURITY_ERROR;
|
||||||
|
} else if (e instanceof RtpContentMap.UnsupportedTransportException) {
|
||||||
|
return UNSUPPORTED_TRANSPORTS;
|
||||||
|
} else if (e instanceof RtpContentMap.UnsupportedApplicationException) {
|
||||||
|
return UNSUPPORTED_APPLICATIONS;
|
||||||
|
} else {
|
||||||
|
return FAILED_APPLICATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Reason ofThrowable(final Throwable throwable) {
|
||||||
|
final Throwable root = Throwables.getRootCause(throwable);
|
||||||
|
if (root instanceof RuntimeException) {
|
||||||
|
return of((RuntimeException) root);
|
||||||
|
}
|
||||||
|
if (root instanceof AxolotlEncryptionException) {
|
||||||
|
return SECURITY_ERROR;
|
||||||
|
}
|
||||||
|
return FAILED_APPLICATION;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,658 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import android.util.Pair;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import com.google.common.collect.Sets;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.Media;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class RtpDescription extends GenericDescription {
|
||||||
|
|
||||||
|
private RtpDescription(final String media) {
|
||||||
|
super("description", Namespace.JINGLE_APPS_RTP);
|
||||||
|
this.setAttribute("media", media);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RtpDescription() {
|
||||||
|
super("description", Namespace.JINGLE_APPS_RTP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpDescription stub(final Media media) {
|
||||||
|
return new RtpDescription(media.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Media getMedia() {
|
||||||
|
return Media.of(this.getAttribute("media"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PayloadType> getPayloadTypes() {
|
||||||
|
final ImmutableList.Builder<PayloadType> builder = new ImmutableList.Builder<>();
|
||||||
|
for (Element child : getChildren()) {
|
||||||
|
if ("payload-type".equals(child.getName())) {
|
||||||
|
builder.add(PayloadType.of(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FeedbackNegotiation> getFeedbackNegotiations() {
|
||||||
|
return FeedbackNegotiation.fromChildren(this.getChildren());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FeedbackNegotiationTrrInt> feedbackNegotiationTrrInts() {
|
||||||
|
return FeedbackNegotiationTrrInt.fromChildren(this.getChildren());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RtpHeaderExtension> getHeaderExtensions() {
|
||||||
|
final ImmutableList.Builder<RtpHeaderExtension> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : getChildren()) {
|
||||||
|
if ("rtp-hdrext".equals(child.getName())
|
||||||
|
&& Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(child.getNamespace())) {
|
||||||
|
builder.add(RtpHeaderExtension.upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Source> getSources() {
|
||||||
|
final ImmutableList.Builder<Source> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : this.children) {
|
||||||
|
if ("source".equals(child.getName())
|
||||||
|
&& Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(
|
||||||
|
child.getNamespace())) {
|
||||||
|
builder.add(Source.upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SourceGroup> getSourceGroups() {
|
||||||
|
final ImmutableList.Builder<SourceGroup> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : this.children) {
|
||||||
|
if ("ssrc-group".equals(child.getName())
|
||||||
|
&& Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(
|
||||||
|
child.getNamespace())) {
|
||||||
|
builder.add(SourceGroup.upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpDescription upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"description".equals(element.getName()),
|
||||||
|
"Name of provided element is not description");
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_APPS_RTP.equals(element.getNamespace()),
|
||||||
|
"Element does not match the jingle rtp namespace");
|
||||||
|
final RtpDescription description = new RtpDescription();
|
||||||
|
description.setAttributes(element.getAttributes());
|
||||||
|
description.setChildren(element.getChildren());
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FeedbackNegotiation extends Element {
|
||||||
|
private FeedbackNegotiation() {
|
||||||
|
super("rtcp-fb", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FeedbackNegotiation(String type, String subType) {
|
||||||
|
super("rtcp-fb", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION);
|
||||||
|
this.setAttribute("type", type);
|
||||||
|
if (subType != null) {
|
||||||
|
this.setAttribute("subtype", subType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return this.getAttribute("type");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSubType() {
|
||||||
|
return this.getAttribute("subtype");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FeedbackNegotiation upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("rtcp-fb".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
|
||||||
|
final FeedbackNegotiation feedback = new FeedbackNegotiation();
|
||||||
|
feedback.setAttributes(element.getAttributes());
|
||||||
|
feedback.setChildren(element.getChildren());
|
||||||
|
return feedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<FeedbackNegotiation> fromChildren(final List<Element> children) {
|
||||||
|
ImmutableList.Builder<FeedbackNegotiation> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : children) {
|
||||||
|
if ("rtcp-fb".equals(child.getName())
|
||||||
|
&& Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
|
||||||
|
builder.add(upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FeedbackNegotiationTrrInt extends Element {
|
||||||
|
|
||||||
|
private FeedbackNegotiationTrrInt(int value) {
|
||||||
|
super("rtcp-fb-trr-int", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION);
|
||||||
|
this.setAttribute("value", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedbackNegotiationTrrInt() {
|
||||||
|
super("rtcp-fb-trr-int", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getValue() {
|
||||||
|
final String value = getAttribute("value");
|
||||||
|
return Integer.parseInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FeedbackNegotiationTrrInt upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("rtcp-fb-trr-int".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
|
||||||
|
final FeedbackNegotiationTrrInt trr = new FeedbackNegotiationTrrInt();
|
||||||
|
trr.setAttributes(element.getAttributes());
|
||||||
|
trr.setChildren(element.getChildren());
|
||||||
|
return trr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<FeedbackNegotiationTrrInt> fromChildren(final List<Element> children) {
|
||||||
|
ImmutableList.Builder<FeedbackNegotiationTrrInt> builder =
|
||||||
|
new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : children) {
|
||||||
|
if ("rtcp-fb-trr-int".equals(child.getName())
|
||||||
|
&& Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
|
||||||
|
builder.add(upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XEP-0294: Jingle RTP Header Extensions Negotiation
|
||||||
|
// maps to `extmap:$id $uri`
|
||||||
|
public static class RtpHeaderExtension extends Element {
|
||||||
|
|
||||||
|
private RtpHeaderExtension() {
|
||||||
|
super("rtp-hdrext", Namespace.JINGLE_RTP_HEADER_EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpHeaderExtension(String id, String uri) {
|
||||||
|
super("rtp-hdrext", Namespace.JINGLE_RTP_HEADER_EXTENSIONS);
|
||||||
|
this.setAttribute("id", id);
|
||||||
|
this.setAttribute("uri", uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return this.getAttribute("id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUri() {
|
||||||
|
return this.getAttribute("uri");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpHeaderExtension upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("rtp-hdrext".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(element.getNamespace()));
|
||||||
|
final RtpHeaderExtension extension = new RtpHeaderExtension();
|
||||||
|
extension.setAttributes(element.getAttributes());
|
||||||
|
extension.setChildren(element.getChildren());
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpHeaderExtension ofSdpString(final String sdp) {
|
||||||
|
final String[] pair = sdp.split(" ", 2);
|
||||||
|
if (pair.length == 2) {
|
||||||
|
final String id = pair[0];
|
||||||
|
final String uri = pair[1];
|
||||||
|
return new RtpHeaderExtension(id, uri);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps to `rtpmap:$id $name/$clockrate/$channels`
|
||||||
|
public static class PayloadType extends Element {
|
||||||
|
|
||||||
|
private PayloadType() {
|
||||||
|
super("payload-type", Namespace.JINGLE_APPS_RTP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PayloadType(String id, String name, int clockRate, int channels) {
|
||||||
|
super("payload-type", Namespace.JINGLE_APPS_RTP);
|
||||||
|
this.setAttribute("id", id);
|
||||||
|
this.setAttribute("name", name);
|
||||||
|
this.setAttribute("clockrate", clockRate);
|
||||||
|
if (channels != 1) {
|
||||||
|
this.setAttribute("channels", channels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toSdpAttribute() {
|
||||||
|
final int channels = getChannels();
|
||||||
|
final String name = getPayloadTypeName();
|
||||||
|
Preconditions.checkArgument(name != null, "Payload-type name must not be empty");
|
||||||
|
SessionDescription.checkNoWhitespace(
|
||||||
|
name, "payload-type name must not contain whitespaces");
|
||||||
|
return getId()
|
||||||
|
+ " "
|
||||||
|
+ name
|
||||||
|
+ "/"
|
||||||
|
+ getClockRate()
|
||||||
|
+ (channels == 1 ? "" : "/" + channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getIntId() {
|
||||||
|
final String id = this.getAttribute("id");
|
||||||
|
return id == null ? 0 : SessionDescription.ignorantIntParser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return this.getAttribute("id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPayloadTypeName() {
|
||||||
|
return this.getAttribute("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getClockRate() {
|
||||||
|
final String clockRate = this.getAttribute("clockrate");
|
||||||
|
if (clockRate == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(clockRate);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getChannels() {
|
||||||
|
final String channels = this.getAttribute("channels");
|
||||||
|
if (channels == null) {
|
||||||
|
return 1; // The number of channels; if omitted, it MUST be assumed to contain one
|
||||||
|
// channel
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(channels);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Parameter> getParameters() {
|
||||||
|
final ImmutableList.Builder<Parameter> builder = new ImmutableList.Builder<>();
|
||||||
|
for (Element child : getChildren()) {
|
||||||
|
if ("parameter".equals(child.getName())) {
|
||||||
|
builder.add(Parameter.of(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FeedbackNegotiation> getFeedbackNegotiations() {
|
||||||
|
return FeedbackNegotiation.fromChildren(this.getChildren());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FeedbackNegotiationTrrInt> feedbackNegotiationTrrInts() {
|
||||||
|
return FeedbackNegotiationTrrInt.fromChildren(this.getChildren());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PayloadType of(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"payload-type".equals(element.getName()),
|
||||||
|
"element name must be called payload-type");
|
||||||
|
PayloadType payloadType = new PayloadType();
|
||||||
|
payloadType.setAttributes(element.getAttributes());
|
||||||
|
payloadType.setChildren(element.getChildren());
|
||||||
|
return payloadType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PayloadType ofSdpString(final String sdp) {
|
||||||
|
final String[] pair = sdp.split(" ", 2);
|
||||||
|
if (pair.length == 2) {
|
||||||
|
final String id = pair[0];
|
||||||
|
final String[] parts = pair[1].split("/");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final String name = parts[0];
|
||||||
|
final int clockRate = SessionDescription.ignorantIntParser(parts[1]);
|
||||||
|
final int channels;
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
channels = SessionDescription.ignorantIntParser(parts[2]);
|
||||||
|
} else {
|
||||||
|
channels = 1;
|
||||||
|
}
|
||||||
|
return new PayloadType(id, name, clockRate, channels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addChildren(final List<Element> children) {
|
||||||
|
if (children != null) {
|
||||||
|
this.children.addAll(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addParameters(List<Parameter> parameters) {
|
||||||
|
if (parameters != null) {
|
||||||
|
this.children.addAll(parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// map to `fmtp $id key=value;key=value
|
||||||
|
// where id is the id of the parent payload-type
|
||||||
|
public static class Parameter extends Element {
|
||||||
|
|
||||||
|
private Parameter() {
|
||||||
|
super("parameter", Namespace.JINGLE_APPS_RTP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Parameter(String name, String value) {
|
||||||
|
super("parameter", Namespace.JINGLE_APPS_RTP);
|
||||||
|
this.setAttribute("name", name);
|
||||||
|
this.setAttribute("value", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getParameterName() {
|
||||||
|
return this.getAttribute("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getParameterValue() {
|
||||||
|
return this.getAttribute("value");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Parameter of(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"parameter".equals(element.getName()), "element name must be called parameter");
|
||||||
|
Parameter parameter = new Parameter();
|
||||||
|
parameter.setAttributes(element.getAttributes());
|
||||||
|
parameter.setChildren(element.getChildren());
|
||||||
|
return parameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toSdpString(final String id, List<Parameter> parameters) {
|
||||||
|
final StringBuilder stringBuilder = new StringBuilder();
|
||||||
|
stringBuilder.append(id).append(' ');
|
||||||
|
for (int i = 0; i < parameters.size(); ++i) {
|
||||||
|
final Parameter p = parameters.get(i);
|
||||||
|
final String name = p.getParameterName();
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
name != null, String.format("parameter for %s must have a name", id));
|
||||||
|
SessionDescription.checkNoWhitespace(
|
||||||
|
name,
|
||||||
|
String.format("parameter names for %s must not contain whitespaces", id));
|
||||||
|
|
||||||
|
final String value = p.getParameterValue();
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
value != null, String.format("parameter for %s must have a value", id));
|
||||||
|
SessionDescription.checkNoWhitespace(
|
||||||
|
value,
|
||||||
|
String.format("parameter values for %s must not contain whitespaces", id));
|
||||||
|
|
||||||
|
stringBuilder.append(name).append('=').append(value);
|
||||||
|
if (i != parameters.size() - 1) {
|
||||||
|
stringBuilder.append(';');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stringBuilder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toSdpString(final String id, final Parameter parameter) {
|
||||||
|
final String name = parameter.getParameterName();
|
||||||
|
final String value = parameter.getParameterValue();
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
value != null, String.format("parameter for %s must have a value", id));
|
||||||
|
SessionDescription.checkNoWhitespace(
|
||||||
|
value,
|
||||||
|
String.format("parameter values for %s must not contain whitespaces", id));
|
||||||
|
if (Strings.isNullOrEmpty(name)) {
|
||||||
|
return String.format("%s %s", id, value);
|
||||||
|
} else {
|
||||||
|
return String.format("%s %s=%s", id, name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Pair<String, List<Parameter>> ofSdpString(final String sdp) {
|
||||||
|
final String[] pair = sdp.split(" ");
|
||||||
|
if (pair.length == 2) {
|
||||||
|
final String id = pair[0];
|
||||||
|
final ImmutableList.Builder<Parameter> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final String parameter : pair[1].split(";")) {
|
||||||
|
final String[] parts = parameter.split("=", 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
builder.add(new Parameter(parts[0], parts[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Pair<>(id, builder.build());
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XEP-0339: Source-Specific Media Attributes in Jingle
|
||||||
|
// maps to `a=ssrc:<ssrc-id> <attribute>:<value>`
|
||||||
|
public static class Source extends Element {
|
||||||
|
|
||||||
|
private Source() {
|
||||||
|
super("source", Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Source(String ssrcId, Collection<Parameter> parameters) {
|
||||||
|
super("source", Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES);
|
||||||
|
this.setAttribute("ssrc", ssrcId);
|
||||||
|
for (Parameter parameter : parameters) {
|
||||||
|
this.addChild(parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSsrcId() {
|
||||||
|
return this.getAttribute("ssrc");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Parameter> getParameters() {
|
||||||
|
ImmutableList.Builder<Parameter> builder = new ImmutableList.Builder<>();
|
||||||
|
for (Element child : this.children) {
|
||||||
|
if ("parameter".equals(child.getName())) {
|
||||||
|
builder.add(Parameter.upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Source upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("source".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(
|
||||||
|
element.getNamespace()));
|
||||||
|
final Source source = new Source();
|
||||||
|
source.setChildren(element.getChildren());
|
||||||
|
source.setAttributes(element.getAttributes());
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Parameter extends Element {
|
||||||
|
|
||||||
|
public String getParameterName() {
|
||||||
|
return this.getAttribute("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getParameterValue() {
|
||||||
|
return this.getAttribute("value");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Parameter() {
|
||||||
|
super("parameter");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Parameter(final String attribute, final String value) {
|
||||||
|
super("parameter");
|
||||||
|
this.setAttribute("name", attribute);
|
||||||
|
if (value != null) {
|
||||||
|
this.setAttribute("value", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Parameter upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("parameter".equals(element.getName()));
|
||||||
|
Parameter parameter = new Parameter();
|
||||||
|
parameter.setAttributes(element.getAttributes());
|
||||||
|
parameter.setChildren(element.getChildren());
|
||||||
|
return parameter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SourceGroup extends Element {
|
||||||
|
|
||||||
|
public SourceGroup(final String semantics, List<String> ssrcs) {
|
||||||
|
this();
|
||||||
|
this.setAttribute("semantics", semantics);
|
||||||
|
for (String ssrc : ssrcs) {
|
||||||
|
this.addChild("source").setAttribute("ssrc", ssrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SourceGroup() {
|
||||||
|
super("ssrc-group", Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSemantics() {
|
||||||
|
return this.getAttribute("semantics");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getSsrcs() {
|
||||||
|
ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
|
||||||
|
for (Element child : this.children) {
|
||||||
|
if ("source".equals(child.getName())) {
|
||||||
|
final String ssrc = child.getAttribute("ssrc");
|
||||||
|
if (ssrc != null) {
|
||||||
|
builder.add(ssrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SourceGroup upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument("ssrc-group".equals(element.getName()));
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(
|
||||||
|
element.getNamespace()));
|
||||||
|
final SourceGroup group = new SourceGroup();
|
||||||
|
group.setChildren(element.getChildren());
|
||||||
|
group.setAttributes(element.getAttributes());
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpDescription of(
|
||||||
|
final SessionDescription sessionDescription, final SessionDescription.Media media) {
|
||||||
|
final RtpDescription rtpDescription = new RtpDescription(media.media);
|
||||||
|
final Map<String, List<Parameter>> parameterMap = new HashMap<>();
|
||||||
|
final ArrayListMultimap<String, Element> feedbackNegotiationMap =
|
||||||
|
ArrayListMultimap.create();
|
||||||
|
final ArrayListMultimap<String, Source.Parameter> sourceParameterMap =
|
||||||
|
ArrayListMultimap.create();
|
||||||
|
final Set<String> attributes =
|
||||||
|
Sets.newHashSet(
|
||||||
|
Iterables.concat(
|
||||||
|
sessionDescription.attributes.keySet(), media.attributes.keySet()));
|
||||||
|
for (final String rtcpFb : media.attributes.get("rtcp-fb")) {
|
||||||
|
final String[] parts = rtcpFb.split(" ");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final String id = parts[0];
|
||||||
|
final String type = parts[1];
|
||||||
|
final String subType = parts.length >= 3 ? parts[2] : null;
|
||||||
|
if ("trr-int".equals(type)) {
|
||||||
|
if (subType != null) {
|
||||||
|
feedbackNegotiationMap.put(
|
||||||
|
id,
|
||||||
|
new FeedbackNegotiationTrrInt(
|
||||||
|
SessionDescription.ignorantIntParser(subType)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feedbackNegotiationMap.put(id, new FeedbackNegotiation(type, subType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final String ssrc : media.attributes.get(("ssrc"))) {
|
||||||
|
final String[] parts = ssrc.split(" ", 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
final String id = parts[0];
|
||||||
|
final String[] subParts = parts[1].split(":", 2);
|
||||||
|
final String attribute = subParts[0];
|
||||||
|
final String value = subParts.length == 2 ? subParts[1] : null;
|
||||||
|
sourceParameterMap.put(id, new Source.Parameter(attribute, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final String fmtp : media.attributes.get("fmtp")) {
|
||||||
|
final Pair<String, List<Parameter>> pair = Parameter.ofSdpString(fmtp);
|
||||||
|
if (pair != null) {
|
||||||
|
parameterMap.put(pair.first, pair.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rtpDescription.addChildren(feedbackNegotiationMap.get("*"));
|
||||||
|
for (final String rtpmap : media.attributes.get("rtpmap")) {
|
||||||
|
final PayloadType payloadType = PayloadType.ofSdpString(rtpmap);
|
||||||
|
if (payloadType != null) {
|
||||||
|
payloadType.addParameters(parameterMap.get(payloadType.getId()));
|
||||||
|
payloadType.addChildren(feedbackNegotiationMap.get(payloadType.getId()));
|
||||||
|
rtpDescription.addChild(payloadType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final String extmap : media.attributes.get("extmap")) {
|
||||||
|
final RtpHeaderExtension extension = RtpHeaderExtension.ofSdpString(extmap);
|
||||||
|
if (extension != null) {
|
||||||
|
rtpDescription.addChild(extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attributes.contains("extmap-allow-mixed")) {
|
||||||
|
rtpDescription.addChild("extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS);
|
||||||
|
}
|
||||||
|
for (final String ssrcGroup : media.attributes.get("ssrc-group")) {
|
||||||
|
final String[] parts = ssrcGroup.split(" ");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
|
||||||
|
final String semantics = parts[0];
|
||||||
|
for (int i = 1; i < parts.length; ++i) {
|
||||||
|
builder.add(parts[i]);
|
||||||
|
}
|
||||||
|
rtpDescription.addChild(new SourceGroup(semantics, builder.build()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Collection<Source.Parameter>> source :
|
||||||
|
sourceParameterMap.asMap().entrySet()) {
|
||||||
|
rtpDescription.addChild(new Source(source.getKey(), source.getValue()));
|
||||||
|
}
|
||||||
|
if (media.attributes.containsKey("rtcp-mux")) {
|
||||||
|
rtpDescription.addChild("rtcp-mux");
|
||||||
|
}
|
||||||
|
return rtpDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addChildren(List<Element> elements) {
|
||||||
|
if (elements != null) {
|
||||||
|
this.children.addAll(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.JingleCandidate;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class S5BTransportInfo extends GenericTransportInfo {
|
||||||
|
|
||||||
|
private S5BTransportInfo(final String name, final String xmlns) {
|
||||||
|
super(name, xmlns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTransportId() {
|
||||||
|
return this.getAttribute("sid");
|
||||||
|
}
|
||||||
|
|
||||||
|
public S5BTransportInfo(
|
||||||
|
final String transportId, final Collection<JingleCandidate> candidates) {
|
||||||
|
super("transport", Namespace.JINGLE_TRANSPORTS_S5B);
|
||||||
|
Preconditions.checkNotNull(transportId, "transport id must not be null");
|
||||||
|
for (JingleCandidate candidate : candidates) {
|
||||||
|
this.addChild(candidate.toElement());
|
||||||
|
}
|
||||||
|
this.setAttribute("sid", transportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public S5BTransportInfo(final String transportId, final Element child) {
|
||||||
|
super("transport", Namespace.JINGLE_TRANSPORTS_S5B);
|
||||||
|
Preconditions.checkNotNull(transportId, "transport id must not be null");
|
||||||
|
this.addChild(child);
|
||||||
|
this.setAttribute("sid", transportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<JingleCandidate> getCandidates() {
|
||||||
|
return JingleCandidate.parse(this.getChildren());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static S5BTransportInfo upgrade(final Element element) {
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
"transport".equals(element.getName()), "Name of provided element is not transport");
|
||||||
|
Preconditions.checkArgument(
|
||||||
|
Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()),
|
||||||
|
"Element does not match s5b transport namespace");
|
||||||
|
final S5BTransportInfo transportInfo =
|
||||||
|
new S5BTransportInfo("transport", Namespace.JINGLE_TRANSPORTS_S5B);
|
||||||
|
transportInfo.setAttributes(element.getAttributes());
|
||||||
|
transportInfo.setChildren(element.getChildren());
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package im.conversations.android;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import im.conversations.android.database.model.Account;
|
||||||
|
|
||||||
|
public abstract class AbstractAccountService {
|
||||||
|
|
||||||
|
protected Context context;
|
||||||
|
protected Account account;
|
||||||
|
|
||||||
|
protected AbstractAccountService(final Context context, final Account account) {
|
||||||
|
this.context = context;
|
||||||
|
this.account = account;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package im.conversations.android.xmpp.axolotl;
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
import org.jxmpp.jid.BareJid;
|
import org.jxmpp.jid.BareJid;
|
||||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
import org.whispersystems.libsignal.SignalProtocolAddress;
|
|
@ -0,0 +1,12 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
public class AxolotlDecryptionException extends Exception {
|
||||||
|
|
||||||
|
public AxolotlDecryptionException(final String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AxolotlDecryptionException(final Throwable throwable) {
|
||||||
|
super(throwable);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
public class AxolotlEncryptionException extends Exception {
|
||||||
|
|
||||||
|
public AxolotlEncryptionException(String msg) {
|
||||||
|
super(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AxolotlEncryptionException(String msg, Exception e) {
|
||||||
|
super(msg, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AxolotlEncryptionException(Exception e) {
|
||||||
|
super(e);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
|
|
||||||
|
public class AxolotlPayload {
|
||||||
|
|
||||||
|
public final AxolotlAddress axolotlAddress;
|
||||||
|
public final IdentityKey identityKey;
|
||||||
|
public final boolean preKeyMessage;
|
||||||
|
public final byte[] payload;
|
||||||
|
|
||||||
|
public AxolotlPayload(
|
||||||
|
AxolotlAddress axolotlAddress,
|
||||||
|
final IdentityKey identityKey,
|
||||||
|
final boolean preKeyMessage,
|
||||||
|
byte[] payload) {
|
||||||
|
this.axolotlAddress = axolotlAddress;
|
||||||
|
this.identityKey = identityKey;
|
||||||
|
this.preKeyMessage = preKeyMessage;
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String payloadAsString() {
|
||||||
|
return new String(payload, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,206 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.OmemoVerification;
|
||||||
|
import im.conversations.android.AbstractAccountService;
|
||||||
|
import im.conversations.android.database.AxolotlDatabaseStore;
|
||||||
|
import im.conversations.android.database.model.Account;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Encrypted;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Header;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Key;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Payload;
|
||||||
|
import java.security.InvalidAlgorithmParameterException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.NoSuchProviderException;
|
||||||
|
import javax.crypto.BadPaddingException;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.IllegalBlockSizeException;
|
||||||
|
import javax.crypto.NoSuchPaddingException;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
import org.whispersystems.libsignal.DuplicateMessageException;
|
||||||
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
|
import org.whispersystems.libsignal.InvalidKeyException;
|
||||||
|
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||||
|
import org.whispersystems.libsignal.InvalidMessageException;
|
||||||
|
import org.whispersystems.libsignal.InvalidVersionException;
|
||||||
|
import org.whispersystems.libsignal.LegacyMessageException;
|
||||||
|
import org.whispersystems.libsignal.NoSessionException;
|
||||||
|
import org.whispersystems.libsignal.UntrustedIdentityException;
|
||||||
|
import org.whispersystems.libsignal.protocol.PreKeySignalMessage;
|
||||||
|
import org.whispersystems.libsignal.protocol.SignalMessage;
|
||||||
|
import org.whispersystems.libsignal.state.SessionRecord;
|
||||||
|
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
||||||
|
|
||||||
|
public class AxolotlService extends AbstractAccountService {
|
||||||
|
|
||||||
|
public static final String KEY_TYPE = "AES";
|
||||||
|
public static final String CIPHER_MODE = "AES/GCM/NoPadding";
|
||||||
|
|
||||||
|
public static final String BOUNCY_CASTLE_PROVIDER = "BC";
|
||||||
|
|
||||||
|
private final SignalProtocolStore signalProtocolStore;
|
||||||
|
|
||||||
|
public AxolotlService(final Context context, final Account account) {
|
||||||
|
super(context, account);
|
||||||
|
this.signalProtocolStore = new AxolotlDatabaseStore(context, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AxolotlSession buildReceivingSession(
|
||||||
|
final Jid from, final IdentityKey identityKey, final Header header) {
|
||||||
|
final Optional<Integer> sid = header.getSourceDevice();
|
||||||
|
if (sid.isPresent()) {
|
||||||
|
return AxolotlSession.of(
|
||||||
|
signalProtocolStore,
|
||||||
|
identityKey,
|
||||||
|
new AxolotlAddress(from.asBareJid(), sid.get()));
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Header did not contain a source device id");
|
||||||
|
}
|
||||||
|
|
||||||
|
public AxolotlSession getExistingSession(final AxolotlAddress axolotlAddress) {
|
||||||
|
final SessionRecord sessionState = signalProtocolStore.loadSession(axolotlAddress);
|
||||||
|
if (sessionState == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final IdentityKey identityKey = sessionState.getSessionState().getRemoteIdentityKey();
|
||||||
|
return AxolotlSession.of(signalProtocolStore, identityKey, axolotlAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AxolotlSession getExistingSessionOrThrow(final AxolotlAddress axolotlAddress)
|
||||||
|
throws NoSessionException {
|
||||||
|
final var session = getExistingSession(axolotlAddress);
|
||||||
|
if (session == null) {
|
||||||
|
throw new NoSessionException(
|
||||||
|
String.format("No session for %s", axolotlAddress.toString()));
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AxolotlPayload decrypt(final Jid from, final Encrypted encrypted)
|
||||||
|
throws AxolotlDecryptionException {
|
||||||
|
try {
|
||||||
|
return decryptOrThrow(from, encrypted);
|
||||||
|
} catch (final IllegalArgumentException
|
||||||
|
| NotEncryptedForThisDeviceException
|
||||||
|
| InvalidMessageException
|
||||||
|
| InvalidVersionException
|
||||||
|
| UntrustedIdentityException
|
||||||
|
| DuplicateMessageException
|
||||||
|
| InvalidKeyIdException
|
||||||
|
| LegacyMessageException
|
||||||
|
| InvalidKeyException
|
||||||
|
| NoSessionException
|
||||||
|
| OutdatedSenderException
|
||||||
|
| NoSuchPaddingException
|
||||||
|
| NoSuchAlgorithmException
|
||||||
|
| NoSuchProviderException
|
||||||
|
| InvalidAlgorithmParameterException
|
||||||
|
| java.security.InvalidKeyException
|
||||||
|
| IllegalBlockSizeException
|
||||||
|
| BadPaddingException e) {
|
||||||
|
throw new AxolotlDecryptionException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AxolotlPayload decryptOrThrow(final Jid from, final Encrypted encrypted)
|
||||||
|
throws NotEncryptedForThisDeviceException, InvalidMessageException,
|
||||||
|
InvalidVersionException, UntrustedIdentityException, DuplicateMessageException,
|
||||||
|
InvalidKeyIdException, LegacyMessageException, InvalidKeyException,
|
||||||
|
NoSessionException, OutdatedSenderException, NoSuchPaddingException,
|
||||||
|
NoSuchAlgorithmException, NoSuchProviderException,
|
||||||
|
InvalidAlgorithmParameterException, java.security.InvalidKeyException,
|
||||||
|
IllegalBlockSizeException, BadPaddingException {
|
||||||
|
final Payload payload = encrypted.getPayload();
|
||||||
|
final Header header = encrypted.getHeader();
|
||||||
|
final Key ourKey = header.getKey(signalProtocolStore.getLocalRegistrationId());
|
||||||
|
if (ourKey == null) {
|
||||||
|
throw new NotEncryptedForThisDeviceException();
|
||||||
|
}
|
||||||
|
final byte[] keyWithAuthTag;
|
||||||
|
final AxolotlSession session;
|
||||||
|
final boolean preKeyMessage;
|
||||||
|
if (ourKey.isPreKey()) {
|
||||||
|
final PreKeySignalMessage preKeySignalMessage =
|
||||||
|
new PreKeySignalMessage(ourKey.asBytes());
|
||||||
|
preKeyMessage = true;
|
||||||
|
session = buildReceivingSession(from, preKeySignalMessage.getIdentityKey(), header);
|
||||||
|
keyWithAuthTag = session.sessionCipher.decrypt(preKeySignalMessage);
|
||||||
|
} else {
|
||||||
|
final SignalMessage signalMessage = new SignalMessage(ourKey.asBytes());
|
||||||
|
preKeyMessage = false;
|
||||||
|
session =
|
||||||
|
getExistingSessionOrThrow(
|
||||||
|
new AxolotlAddress(from.asBareJid(), header.getSourceDevice().get()));
|
||||||
|
keyWithAuthTag = session.sessionCipher.decrypt(signalMessage);
|
||||||
|
}
|
||||||
|
if (keyWithAuthTag.length < 32) {
|
||||||
|
throw new OutdatedSenderException(
|
||||||
|
"Key did not contain auth tag. Sender needs to update their OMEMO client");
|
||||||
|
}
|
||||||
|
if (payload == null) {
|
||||||
|
return new AxolotlPayload(
|
||||||
|
session.axolotlAddress, session.identityKey, preKeyMessage, null);
|
||||||
|
}
|
||||||
|
final byte[] key = new byte[16];
|
||||||
|
final byte[] authTag = new byte[16];
|
||||||
|
final byte[] iv = header.getIv();
|
||||||
|
System.arraycopy(keyWithAuthTag, 0, key, 0, key.length);
|
||||||
|
System.arraycopy(keyWithAuthTag, key.length, authTag, 0, authTag.length);
|
||||||
|
final Cipher cipher;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
cipher = Cipher.getInstance(CIPHER_MODE);
|
||||||
|
} else {
|
||||||
|
cipher = Cipher.getInstance(CIPHER_MODE, BOUNCY_CASTLE_PROVIDER);
|
||||||
|
}
|
||||||
|
final SecretKey secretKey = new SecretKeySpec(key, KEY_TYPE);
|
||||||
|
final IvParameterSpec ivSpec = new IvParameterSpec(iv);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
|
||||||
|
final byte[] payloadAsBytes = payload.asBytes();
|
||||||
|
final byte[] payloadWithAuthTag = new byte[payloadAsBytes.length + 16];
|
||||||
|
System.arraycopy(payloadAsBytes, 0, payloadWithAuthTag, 0, payloadAsBytes.length);
|
||||||
|
System.arraycopy(authTag, 0, payloadWithAuthTag, payloadAsBytes.length, authTag.length);
|
||||||
|
final byte[] decryptedPayload = cipher.doFinal(payloadWithAuthTag);
|
||||||
|
return new AxolotlPayload(
|
||||||
|
session.axolotlAddress, session.identityKey, preKeyMessage, decryptedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignalProtocolStore getSignalProtocolStore() {
|
||||||
|
return this.signalProtocolStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OmemoVerifiedPayload<T> {
|
||||||
|
private final int deviceId;
|
||||||
|
private final IdentityKey identityKey;
|
||||||
|
private final T payload;
|
||||||
|
|
||||||
|
public OmemoVerifiedPayload(OmemoVerification omemoVerification, T payload) {
|
||||||
|
this.deviceId = omemoVerification.getDeviceId();
|
||||||
|
this.identityKey = omemoVerification.getFingerprint();
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IdentityKey getFingerprint() {
|
||||||
|
return identityKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T getPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NotVerifiedException extends SecurityException {
|
||||||
|
|
||||||
|
public NotVerifiedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
|
import org.whispersystems.libsignal.SessionCipher;
|
||||||
|
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
||||||
|
|
||||||
|
public class AxolotlSession {
|
||||||
|
|
||||||
|
public final AxolotlAddress axolotlAddress;
|
||||||
|
public final IdentityKey identityKey;
|
||||||
|
public final SessionCipher sessionCipher;
|
||||||
|
|
||||||
|
private AxolotlSession(
|
||||||
|
AxolotlAddress axolotlAddress,
|
||||||
|
final IdentityKey identityKey,
|
||||||
|
SessionCipher sessionCipher) {
|
||||||
|
this.axolotlAddress = axolotlAddress;
|
||||||
|
this.identityKey = identityKey;
|
||||||
|
this.sessionCipher = sessionCipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AxolotlSession of(
|
||||||
|
final SignalProtocolStore signalProtocolStore,
|
||||||
|
final IdentityKey identityKey,
|
||||||
|
final AxolotlAddress axolotlAddress) {
|
||||||
|
final var sessionCipher = new SessionCipher(signalProtocolStore, axolotlAddress);
|
||||||
|
return new AxolotlSession(axolotlAddress, identityKey, sessionCipher);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,157 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.os.Build;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import im.conversations.android.Conversations;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Encrypted;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Header;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Key;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Payload;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.InvalidAlgorithmParameterException;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.NoSuchProviderException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import javax.crypto.BadPaddingException;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.IllegalBlockSizeException;
|
||||||
|
import javax.crypto.KeyGenerator;
|
||||||
|
import javax.crypto.NoSuchPaddingException;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import org.whispersystems.libsignal.UntrustedIdentityException;
|
||||||
|
import org.whispersystems.libsignal.protocol.CiphertextMessage;
|
||||||
|
|
||||||
|
public class EncryptionBuilder {
|
||||||
|
|
||||||
|
private Long sourceDeviceId;
|
||||||
|
|
||||||
|
private ArrayList<AxolotlSession> sessions;
|
||||||
|
|
||||||
|
private byte[] payload;
|
||||||
|
|
||||||
|
public Encrypted build() throws AxolotlEncryptionException {
|
||||||
|
try {
|
||||||
|
return buildOrThrow();
|
||||||
|
} catch (final InvalidAlgorithmParameterException
|
||||||
|
| NoSuchPaddingException
|
||||||
|
| IllegalBlockSizeException
|
||||||
|
| NoSuchAlgorithmException
|
||||||
|
| BadPaddingException
|
||||||
|
| NoSuchProviderException
|
||||||
|
| InvalidKeyException
|
||||||
|
| UntrustedIdentityException e) {
|
||||||
|
throw new AxolotlEncryptionException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Encrypted buildOrThrow()
|
||||||
|
throws InvalidAlgorithmParameterException, NoSuchPaddingException,
|
||||||
|
IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException,
|
||||||
|
NoSuchProviderException, InvalidKeyException, UntrustedIdentityException {
|
||||||
|
final long sourceDeviceId =
|
||||||
|
Preconditions.checkNotNull(this.sourceDeviceId, "Specify a source device id");
|
||||||
|
final var payloadCleartext = Preconditions.checkNotNull(this.payload, "Specify a payload");
|
||||||
|
Preconditions.checkState(sessions.size() > 0, "Add at least on session");
|
||||||
|
final var sessions = ImmutableList.copyOf(this.sessions);
|
||||||
|
final var key = generateKey();
|
||||||
|
final var iv = generateIv();
|
||||||
|
final var encryptedPayload = encrypt(payloadCleartext, key, iv);
|
||||||
|
final var keyWithAuthTag = new byte[32];
|
||||||
|
System.arraycopy(key, 0, keyWithAuthTag, 0, key.length);
|
||||||
|
System.arraycopy(
|
||||||
|
encryptedPayload.authTag, 0, keyWithAuthTag, 16, encryptedPayload.authTag.length);
|
||||||
|
final var header = buildHeader(sessions, keyWithAuthTag);
|
||||||
|
header.addIv(iv);
|
||||||
|
header.setSourceDevice(sourceDeviceId);
|
||||||
|
final var encrypted = new Encrypted();
|
||||||
|
encrypted.addExtension(header);
|
||||||
|
final var payload = encrypted.addExtension(new Payload());
|
||||||
|
payload.setContent(encryptedPayload.encrypted);
|
||||||
|
return encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EncryptionBuilder payload(final String payload) {
|
||||||
|
this.payload = payload.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EncryptionBuilder session(final AxolotlSession session) {
|
||||||
|
this.sessions.add(session);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EncryptionBuilder sourceDeviceId(final long sourceDeviceId) {
|
||||||
|
this.sourceDeviceId = sourceDeviceId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Header buildHeader(List<AxolotlSession> sessions, final byte[] keyWithAuthTag)
|
||||||
|
throws UntrustedIdentityException {
|
||||||
|
final var header = new Header();
|
||||||
|
for (final AxolotlSession session : sessions) {
|
||||||
|
final var cipherMessage = session.sessionCipher.encrypt(keyWithAuthTag);
|
||||||
|
final var key = header.addExtension(new Key());
|
||||||
|
key.setContent(cipherMessage.serialize());
|
||||||
|
key.setIsPreKey(cipherMessage.getType() == CiphertextMessage.PREKEY_TYPE);
|
||||||
|
}
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("DeprecatedProvider")
|
||||||
|
private static EncryptedPayload encrypt(
|
||||||
|
final byte[] payloadCleartext, final byte[] key, final byte[] iv)
|
||||||
|
throws NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException,
|
||||||
|
IllegalBlockSizeException, BadPaddingException,
|
||||||
|
InvalidAlgorithmParameterException, InvalidKeyException {
|
||||||
|
final Cipher cipher;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
cipher = Cipher.getInstance(AxolotlService.CIPHER_MODE);
|
||||||
|
} else {
|
||||||
|
cipher =
|
||||||
|
Cipher.getInstance(
|
||||||
|
AxolotlService.CIPHER_MODE, AxolotlService.BOUNCY_CASTLE_PROVIDER);
|
||||||
|
}
|
||||||
|
final SecretKey secretKey = new SecretKeySpec(key, AxolotlService.KEY_TYPE);
|
||||||
|
final IvParameterSpec ivSpec = new IvParameterSpec(iv);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
|
||||||
|
final var encryptedWithAuthTag = cipher.doFinal(payloadCleartext);
|
||||||
|
final var authTag = new byte[16];
|
||||||
|
final var encrypted = new byte[encryptedWithAuthTag.length - authTag.length];
|
||||||
|
|
||||||
|
System.arraycopy(encryptedWithAuthTag, 0, encrypted, 0, encrypted.length);
|
||||||
|
System.arraycopy(encryptedWithAuthTag, encrypted.length, authTag, 0, authTag.length);
|
||||||
|
return new EncryptedPayload(encrypted, authTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] generateKey() {
|
||||||
|
try {
|
||||||
|
KeyGenerator generator = KeyGenerator.getInstance(AxolotlService.KEY_TYPE);
|
||||||
|
generator.init(128);
|
||||||
|
return generator.generateKey().getEncoded();
|
||||||
|
} catch (final NoSuchAlgorithmException e) {
|
||||||
|
throw new AssertionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class EncryptedPayload {
|
||||||
|
public final byte[] encrypted;
|
||||||
|
public final byte[] authTag;
|
||||||
|
|
||||||
|
private EncryptedPayload(byte[] encrypted, byte[] authTag) {
|
||||||
|
this.encrypted = encrypted;
|
||||||
|
this.authTag = authTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] generateIv() {
|
||||||
|
final byte[] iv = new byte[12];
|
||||||
|
Conversations.SECURE_RANDOM.nextBytes(iv);
|
||||||
|
return iv;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2018, Daniel Gultsch All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
* are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
* list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation and/or
|
||||||
|
* other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
public class NotEncryptedForThisDeviceException extends AxolotlEncryptionException {
|
||||||
|
public NotEncryptedForThisDeviceException() {
|
||||||
|
super("Message was not encrypted for this device");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package im.conversations.android.axolotl;
|
||||||
|
|
||||||
|
public class OutdatedSenderException extends AxolotlDecryptionException {
|
||||||
|
|
||||||
|
public OutdatedSenderException(final String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
package im.conversations.android.database;
|
package im.conversations.android.database;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import im.conversations.android.AbstractAccountService;
|
||||||
|
import im.conversations.android.axolotl.AxolotlAddress;
|
||||||
import im.conversations.android.database.dao.AxolotlDao;
|
import im.conversations.android.database.dao.AxolotlDao;
|
||||||
import im.conversations.android.database.model.Account;
|
import im.conversations.android.database.model.Account;
|
||||||
import im.conversations.android.xmpp.axolotl.AxolotlAddress;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.whispersystems.libsignal.IdentityKey;
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||||
|
@ -14,14 +15,10 @@ import org.whispersystems.libsignal.state.SessionRecord;
|
||||||
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
||||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||||
|
|
||||||
public class AxolotlDatabaseStore implements SignalProtocolStore {
|
public class AxolotlDatabaseStore extends AbstractAccountService implements SignalProtocolStore {
|
||||||
|
|
||||||
private final Context context;
|
|
||||||
private final Account account;
|
|
||||||
|
|
||||||
public AxolotlDatabaseStore(final Context context, final Account account) {
|
public AxolotlDatabaseStore(final Context context, final Account account) {
|
||||||
this.context = context;
|
super(context, account);
|
||||||
this.account = account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AxolotlDao axolotlDao() {
|
private AxolotlDao axolotlDao() {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import im.conversations.android.xmpp.model.disco.info.Identity;
|
||||||
import im.conversations.android.xmpp.model.disco.info.InfoQuery;
|
import im.conversations.android.xmpp.model.disco.info.InfoQuery;
|
||||||
import im.conversations.android.xmpp.model.disco.items.Item;
|
import im.conversations.android.xmpp.model.disco.items.Item;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import org.jxmpp.jid.BareJid;
|
||||||
import org.jxmpp.jid.Jid;
|
import org.jxmpp.jid.Jid;
|
||||||
import org.jxmpp.jid.parts.Resourcepart;
|
import org.jxmpp.jid.parts.Resourcepart;
|
||||||
|
|
||||||
|
@ -186,5 +187,44 @@ public abstract class DiscoDao {
|
||||||
"SELECT EXISTS (SELECT disco_item.id FROM disco_item JOIN disco_feature on"
|
"SELECT EXISTS (SELECT disco_item.id FROM disco_item JOIN disco_feature on"
|
||||||
+ " disco_item.discoId=disco_feature.discoId WHERE accountId=:account AND"
|
+ " disco_item.discoId=disco_feature.discoId WHERE accountId=:account AND"
|
||||||
+ " address=:entity AND feature=:feature)")
|
+ " address=:entity AND feature=:feature)")
|
||||||
public abstract boolean hasFeature(final long account, final Jid entity, final String feature);
|
protected abstract boolean hasDiscoItemFeature(
|
||||||
|
final long account, final Jid entity, final String feature);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT EXISTS (SELECT presence.id FROM presence JOIN disco_feature on"
|
||||||
|
+ " presence.discoId=disco_feature.discoId WHERE accountId=:account AND"
|
||||||
|
+ " address=:address AND resource=:resource AND feature=:feature)")
|
||||||
|
protected abstract boolean hasPresenceFeature(
|
||||||
|
final long account,
|
||||||
|
final BareJid address,
|
||||||
|
final Resourcepart resource,
|
||||||
|
final String feature);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT count(presence.id) FROM presence JOIN disco_feature on"
|
||||||
|
+ " presence.discoId=disco_feature.discoId WHERE accountId=:account AND"
|
||||||
|
+ " address=:address AND feature=:feature")
|
||||||
|
public abstract int countPresencesWithFeature(
|
||||||
|
final long account, final BareJid address, final String feature);
|
||||||
|
|
||||||
|
public int countPresencesWithFeature(final Account account, final String feature) {
|
||||||
|
return countPresencesWithFeature(account.id, account.address, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasFeature(final long account, final Entity entity, final String feature) {
|
||||||
|
if (entity instanceof Entity.DiscoItem) {
|
||||||
|
return hasDiscoItemFeature(account, entity.address, feature);
|
||||||
|
}
|
||||||
|
if (entity instanceof Entity.Presence) {
|
||||||
|
return hasPresenceFeature(
|
||||||
|
account,
|
||||||
|
entity.address.asBareJid(),
|
||||||
|
entity.address.getResourceOrEmpty(),
|
||||||
|
feature);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
String.format(
|
||||||
|
"Discovering features for %s is not implemented",
|
||||||
|
entity.getClass().getName()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity;
|
||||||
import im.conversations.android.database.model.Account;
|
import im.conversations.android.database.model.Account;
|
||||||
import im.conversations.android.xmpp.model.roster.Item;
|
import im.conversations.android.xmpp.model.roster.Item;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import org.jxmpp.jid.BareJid;
|
||||||
import org.jxmpp.jid.Jid;
|
import org.jxmpp.jid.Jid;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
@ -33,6 +34,9 @@ public abstract class RosterDao extends GroupDao {
|
||||||
@Query("UPDATE account SET rosterVersion=:version WHERE id=:account")
|
@Query("UPDATE account SET rosterVersion=:version WHERE id=:account")
|
||||||
protected abstract void setRosterVersion(final long account, final String version);
|
protected abstract void setRosterVersion(final long account, final String version);
|
||||||
|
|
||||||
|
@Query("SELECT EXISTS (SELECT id FROM roster WHERE accountId=:account AND address=:address)")
|
||||||
|
public abstract boolean isInRoster(final long account, final BareJid address);
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
public void set(
|
public void set(
|
||||||
final Account account, final String version, final Collection<Item> rosterItems) {
|
final Account account, final String version, final Collection<Item> rosterItems) {
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package im.conversations.android.notification;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
import im.conversations.android.R;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
|
||||||
|
public abstract class AbstractNotification {
|
||||||
|
|
||||||
|
protected static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE =
|
||||||
|
Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
|
protected final Context context;
|
||||||
|
|
||||||
|
protected AbstractNotification(final Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean notificationsFromStrangers() {
|
||||||
|
final SharedPreferences preferences =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
return preferences.getBoolean(
|
||||||
|
"notifications_from_strangers",
|
||||||
|
context.getResources().getBoolean(R.bool.notifications_from_strangers));
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,10 +11,11 @@ import im.conversations.android.R;
|
||||||
|
|
||||||
public final class Channels {
|
public final class Channels {
|
||||||
|
|
||||||
private final Application application;
|
|
||||||
|
|
||||||
private static final String CHANNEL_GROUP_STATUS = "status";
|
|
||||||
static final String CHANNEL_FOREGROUND = "foreground";
|
static final String CHANNEL_FOREGROUND = "foreground";
|
||||||
|
static final String INCOMING_CALLS_NOTIFICATION_CHANNEL = "incoming_calls_channel";
|
||||||
|
static final String CHANNEL_GROUP_STATUS = "status";
|
||||||
|
static final String CHANNEL_GROUP_CALLS = "calls";
|
||||||
|
private final Application application;
|
||||||
|
|
||||||
public Channels(final Application application) {
|
public Channels(final Application application) {
|
||||||
this.application = application;
|
this.application = application;
|
||||||
|
@ -29,6 +30,8 @@ public final class Channels {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
this.initializeGroups(notificationManager);
|
this.initializeGroups(notificationManager);
|
||||||
this.initializeForegroundChannel(notificationManager);
|
this.initializeForegroundChannel(notificationManager);
|
||||||
|
|
||||||
|
this.initializeIncomingCallChannel(notificationManager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,6 +41,10 @@ public final class Channels {
|
||||||
new NotificationChannelGroup(
|
new NotificationChannelGroup(
|
||||||
CHANNEL_GROUP_STATUS,
|
CHANNEL_GROUP_STATUS,
|
||||||
application.getString(R.string.notification_group_status_information)));
|
application.getString(R.string.notification_group_status_information)));
|
||||||
|
notificationManager.createNotificationChannelGroup(
|
||||||
|
new NotificationChannelGroup(
|
||||||
|
CHANNEL_GROUP_CALLS,
|
||||||
|
application.getString(R.string.notification_group_calls)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
|
@ -55,4 +62,21 @@ public final class Channels {
|
||||||
foregroundServiceChannel.setGroup(CHANNEL_GROUP_STATUS);
|
foregroundServiceChannel.setGroup(CHANNEL_GROUP_STATUS);
|
||||||
notificationManager.createNotificationChannel(foregroundServiceChannel);
|
notificationManager.createNotificationChannel(foregroundServiceChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
|
private void initializeIncomingCallChannel(final NotificationManager notificationManager) {
|
||||||
|
final NotificationChannel incomingCallsChannel =
|
||||||
|
new NotificationChannel(
|
||||||
|
INCOMING_CALLS_NOTIFICATION_CHANNEL,
|
||||||
|
application.getString(R.string.incoming_calls_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH);
|
||||||
|
incomingCallsChannel.setSound(null, null);
|
||||||
|
incomingCallsChannel.setShowBadge(false);
|
||||||
|
incomingCallsChannel.setLightColor(RtpSessionNotification.LED_COLOR);
|
||||||
|
incomingCallsChannel.enableLights(true);
|
||||||
|
incomingCallsChannel.setGroup(CHANNEL_GROUP_CALLS);
|
||||||
|
incomingCallsChannel.setBypassDnd(true);
|
||||||
|
incomingCallsChannel.enableVibration(false);
|
||||||
|
notificationManager.createNotificationChannel(incomingCallsChannel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,28 +11,26 @@ import im.conversations.android.R;
|
||||||
import im.conversations.android.ui.activity.MainActivity;
|
import im.conversations.android.ui.activity.MainActivity;
|
||||||
import im.conversations.android.xmpp.ConnectionPool;
|
import im.conversations.android.xmpp.ConnectionPool;
|
||||||
|
|
||||||
public class ForegroundServiceNotification {
|
public class ForegroundServiceNotification extends AbstractNotification {
|
||||||
|
|
||||||
public static final int ID = 1;
|
public static final int ID = 1;
|
||||||
|
|
||||||
private final Service service;
|
|
||||||
|
|
||||||
public ForegroundServiceNotification(final Service service) {
|
public ForegroundServiceNotification(final Service service) {
|
||||||
this.service = service;
|
super(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Notification build(final ConnectionPool.Summary summary) {
|
public Notification build(final ConnectionPool.Summary summary) {
|
||||||
final Notification.Builder builder = new Notification.Builder(service);
|
final Notification.Builder builder = new Notification.Builder(context);
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
// starting with Android 7 the app name is displayed as part of the notification
|
// starting with Android 7 the app name is displayed as part of the notification
|
||||||
// this means we do not have to repeat it in the 'content title'
|
// this means we do not have to repeat it in the 'content title'
|
||||||
builder.setContentTitle(
|
builder.setContentTitle(
|
||||||
service.getString(
|
context.getString(
|
||||||
R.string.connected_accounts, summary.connected, summary.total));
|
R.string.connected_accounts, summary.connected, summary.total));
|
||||||
} else {
|
} else {
|
||||||
builder.setContentTitle(service.getString(R.string.app_name));
|
builder.setContentTitle(context.getString(R.string.app_name));
|
||||||
builder.setContentText(
|
builder.setContentText(
|
||||||
service.getString(
|
context.getString(
|
||||||
R.string.connected_accounts, summary.connected, summary.total));
|
R.string.connected_accounts, summary.connected, summary.total));
|
||||||
}
|
}
|
||||||
builder.setContentIntent(buildPendingIntent());
|
builder.setContentIntent(buildPendingIntent());
|
||||||
|
@ -53,15 +51,15 @@ public class ForegroundServiceNotification {
|
||||||
|
|
||||||
private PendingIntent buildPendingIntent() {
|
private PendingIntent buildPendingIntent() {
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
service,
|
context,
|
||||||
0,
|
0,
|
||||||
new Intent(service, MainActivity.class),
|
new Intent(context, MainActivity.class),
|
||||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void update(final ConnectionPool.Summary summary) {
|
public void update(final ConnectionPool.Summary summary) {
|
||||||
final var notificationManager =
|
final var notificationManager =
|
||||||
ContextCompat.getSystemService(service, NotificationManager.class);
|
ContextCompat.getSystemService(context, NotificationManager.class);
|
||||||
if (notificationManager == null) {
|
if (notificationManager == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package im.conversations.android.notification;
|
||||||
|
|
||||||
|
import com.google.common.base.Objects;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.Media;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class OngoingCall {
|
||||||
|
public final AbstractJingleConnection.Id id;
|
||||||
|
public final Set<Media> media;
|
||||||
|
public final boolean reconnecting;
|
||||||
|
|
||||||
|
public OngoingCall(
|
||||||
|
AbstractJingleConnection.Id id, Set<Media> media, final boolean reconnecting) {
|
||||||
|
this.id = id;
|
||||||
|
this.media = media;
|
||||||
|
this.reconnecting = reconnecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
OngoingCall that = (OngoingCall) o;
|
||||||
|
return reconnecting == that.reconnecting
|
||||||
|
&& Objects.equal(id, that.id)
|
||||||
|
&& Objects.equal(media, that.media);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(id, media, reconnecting);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,284 @@
|
||||||
|
package im.conversations.android.notification;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.content.res.Resources;
|
||||||
|
import android.media.Ringtone;
|
||||||
|
import android.media.RingtoneManager;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Vibrator;
|
||||||
|
import android.preference.PreferenceManager;
|
||||||
|
import android.util.Log;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.Media;
|
||||||
|
import im.conversations.android.R;
|
||||||
|
import im.conversations.android.database.model.Account;
|
||||||
|
import im.conversations.android.service.RtpSessionService;
|
||||||
|
import im.conversations.android.transformer.CallLogEntry;
|
||||||
|
import im.conversations.android.ui.activity.RtpSessionActivity;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
public class RtpSessionNotification extends AbstractNotification {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(RtpSessionNotification.class);
|
||||||
|
|
||||||
|
public static final int INCOMING_CALL_ID = 2;
|
||||||
|
|
||||||
|
public static final int LED_COLOR = 0xff00ff00;
|
||||||
|
|
||||||
|
private static final long[] CALL_PATTERN = {0, 500, 300, 600};
|
||||||
|
|
||||||
|
private Ringtone currentlyPlayingRingtone = null;
|
||||||
|
private ScheduledFuture<?> vibrationFuture;
|
||||||
|
|
||||||
|
public RtpSessionNotification(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelIncomingCallNotification() {
|
||||||
|
stopSoundAndVibration();
|
||||||
|
cancel(INCOMING_CALL_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean stopSoundAndVibration() {
|
||||||
|
int stopped = 0;
|
||||||
|
if (this.currentlyPlayingRingtone != null) {
|
||||||
|
if (this.currentlyPlayingRingtone.isPlaying()) {
|
||||||
|
Log.d(Config.LOGTAG, "stop playing ring tone");
|
||||||
|
++stopped;
|
||||||
|
}
|
||||||
|
this.currentlyPlayingRingtone.stop();
|
||||||
|
}
|
||||||
|
if (this.vibrationFuture != null && !this.vibrationFuture.isCancelled()) {
|
||||||
|
Log.d(Config.LOGTAG, "stop vibration");
|
||||||
|
this.vibrationFuture.cancel(true);
|
||||||
|
++stopped;
|
||||||
|
}
|
||||||
|
return stopped > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notify(int id, Notification notification) {
|
||||||
|
final var notificationManager = NotificationManagerCompat.from(context);
|
||||||
|
try {
|
||||||
|
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
LOGGER.warn("Lacking notification permission");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notificationManager.notify(id, notification);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOGGER.warn("Could not post notification", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancel(final int notificationId) {
|
||||||
|
final var notificationManager = NotificationManagerCompat.from(context);
|
||||||
|
notificationManager.cancel(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void startRinging(
|
||||||
|
final Account account, final AbstractJingleConnection.Id id, final Set<Media> media) {
|
||||||
|
showIncomingCallNotification(account, id, media);
|
||||||
|
final NotificationManager notificationManager =
|
||||||
|
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
final int currentInterruptionFilter;
|
||||||
|
if (notificationManager != null) {
|
||||||
|
currentInterruptionFilter = notificationManager.getCurrentInterruptionFilter();
|
||||||
|
} else {
|
||||||
|
currentInterruptionFilter = 1; // INTERRUPTION_FILTER_ALL
|
||||||
|
}
|
||||||
|
if (currentInterruptionFilter != 1) {
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"do not ring or vibrate because interruption filter has been set to "
|
||||||
|
+ currentInterruptionFilter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ScheduledFuture<?> currentVibrationFuture = this.vibrationFuture;
|
||||||
|
this.vibrationFuture =
|
||||||
|
SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(
|
||||||
|
new VibrationRunnable(), 0, 3, TimeUnit.SECONDS);
|
||||||
|
if (currentVibrationFuture != null) {
|
||||||
|
currentVibrationFuture.cancel(true);
|
||||||
|
}
|
||||||
|
final SharedPreferences preferences =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
final Resources resources = context.getResources();
|
||||||
|
final String ringtonePreference =
|
||||||
|
preferences.getString(
|
||||||
|
"call_ringtone", resources.getString(R.string.incoming_call_ringtone));
|
||||||
|
if (Strings.isNullOrEmpty(ringtonePreference)) {
|
||||||
|
Log.d(Config.LOGTAG, "ringtone has been set to none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Uri uri = Uri.parse(ringtonePreference);
|
||||||
|
this.currentlyPlayingRingtone = RingtoneManager.getRingtone(context, uri);
|
||||||
|
if (this.currentlyPlayingRingtone == null) {
|
||||||
|
Log.d(Config.LOGTAG, "unable to find ringtone for uri " + uri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
this.currentlyPlayingRingtone.setLooping(true);
|
||||||
|
}
|
||||||
|
this.currentlyPlayingRingtone.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showIncomingCallNotification(
|
||||||
|
final Account account, final AbstractJingleConnection.Id id, final Set<Media> media) {
|
||||||
|
final Intent fullScreenIntent = new Intent(context, RtpSessionActivity.class);
|
||||||
|
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.id);
|
||||||
|
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_WITH, id.with.toString());
|
||||||
|
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, id.sessionId);
|
||||||
|
fullScreenIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
fullScreenIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
|
final NotificationCompat.Builder builder =
|
||||||
|
new NotificationCompat.Builder(
|
||||||
|
context, Channels.INCOMING_CALLS_NOTIFICATION_CHANNEL);
|
||||||
|
if (media.contains(Media.VIDEO)) {
|
||||||
|
builder.setSmallIcon(R.drawable.ic_videocam_24dp);
|
||||||
|
builder.setContentTitle(context.getString(R.string.rtp_state_incoming_video_call));
|
||||||
|
} else {
|
||||||
|
builder.setSmallIcon(R.drawable.ic_call_24dp);
|
||||||
|
builder.setContentTitle(context.getString(R.string.rtp_state_incoming_call));
|
||||||
|
}
|
||||||
|
// TODO fix me once we have a contact model
|
||||||
|
/*final Contact contact = id.getContact();
|
||||||
|
builder.setLargeIcon(
|
||||||
|
mXmppConnectionService
|
||||||
|
.getAvatarService()
|
||||||
|
.get(contact, AvatarService.getSystemUiAvatarSize(mXmppConnectionService)));
|
||||||
|
final Uri systemAccount = contact.getSystemAccount();
|
||||||
|
if (systemAccount != null) {
|
||||||
|
builder.addPerson(systemAccount.toString());
|
||||||
|
}
|
||||||
|
builder.setContentText(id.account.getRoster().getContact(id.with).getDisplayName());*/
|
||||||
|
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
|
||||||
|
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
|
||||||
|
builder.setCategory(NotificationCompat.CATEGORY_CALL);
|
||||||
|
PendingIntent pendingIntent = createPendingRtpSession(account, id, Intent.ACTION_VIEW, 101);
|
||||||
|
builder.setFullScreenIntent(pendingIntent, true);
|
||||||
|
builder.setContentIntent(pendingIntent); // old androids need this?
|
||||||
|
builder.setOngoing(true);
|
||||||
|
builder.addAction(
|
||||||
|
new NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_call_end_24dp,
|
||||||
|
context.getString(R.string.dismiss_call),
|
||||||
|
createCallAction(
|
||||||
|
id.sessionId, RtpSessionService.ACTION_DISMISS_CALL, 102))
|
||||||
|
.build());
|
||||||
|
builder.addAction(
|
||||||
|
new NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_call_24dp,
|
||||||
|
context.getString(R.string.answer_call),
|
||||||
|
createPendingRtpSession(
|
||||||
|
account, id, RtpSessionActivity.ACTION_ACCEPT_CALL, 103))
|
||||||
|
.build());
|
||||||
|
modifyIncomingCall(builder);
|
||||||
|
final Notification notification = builder.build();
|
||||||
|
notification.flags = notification.flags | Notification.FLAG_INSISTENT;
|
||||||
|
notify(INCOMING_CALL_ID, notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Notification getOngoingCallNotification(final Account account, OngoingCall ongoingCall) {
|
||||||
|
final AbstractJingleConnection.Id id = ongoingCall.id;
|
||||||
|
final NotificationCompat.Builder builder =
|
||||||
|
new NotificationCompat.Builder(context, "ongoing_calls");
|
||||||
|
if (ongoingCall.media.contains(Media.VIDEO)) {
|
||||||
|
builder.setSmallIcon(R.drawable.ic_videocam_24dp);
|
||||||
|
if (ongoingCall.reconnecting) {
|
||||||
|
builder.setContentTitle(context.getString(R.string.reconnecting_video_call));
|
||||||
|
} else {
|
||||||
|
builder.setContentTitle(context.getString(R.string.ongoing_video_call));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
builder.setSmallIcon(R.drawable.ic_call_24dp);
|
||||||
|
if (ongoingCall.reconnecting) {
|
||||||
|
builder.setContentTitle(context.getString(R.string.reconnecting_call));
|
||||||
|
} else {
|
||||||
|
builder.setContentTitle(context.getString(R.string.ongoing_call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO fix me when we have a Contact model
|
||||||
|
// builder.setContentText(id.account.getRoster().getContact(id.with).getDisplayName());
|
||||||
|
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
|
||||||
|
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
|
||||||
|
builder.setCategory(NotificationCompat.CATEGORY_CALL);
|
||||||
|
builder.setContentIntent(createPendingRtpSession(account, id, Intent.ACTION_VIEW, 101));
|
||||||
|
builder.setOngoing(true);
|
||||||
|
builder.addAction(
|
||||||
|
new NotificationCompat.Action.Builder(
|
||||||
|
R.drawable.ic_call_end_24dp,
|
||||||
|
context.getString(R.string.hang_up),
|
||||||
|
createCallAction(
|
||||||
|
id.sessionId, RtpSessionService.ACTION_END_CALL, 104))
|
||||||
|
.build());
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent createPendingRtpSession(
|
||||||
|
final Account account,
|
||||||
|
final AbstractJingleConnection.Id id,
|
||||||
|
final String action,
|
||||||
|
final int requestCode) {
|
||||||
|
final Intent fullScreenIntent = new Intent(context, RtpSessionActivity.class);
|
||||||
|
fullScreenIntent.setAction(action);
|
||||||
|
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.id);
|
||||||
|
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_WITH, id.with.toString());
|
||||||
|
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, id.sessionId);
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
fullScreenIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent createCallAction(String sessionId, final String action, int requestCode) {
|
||||||
|
final Intent intent = new Intent(context, RtpSessionService.class);
|
||||||
|
intent.setAction(action);
|
||||||
|
intent.setPackage(context.getPackageName());
|
||||||
|
intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId);
|
||||||
|
return PendingIntent.getService(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void modifyIncomingCall(final NotificationCompat.Builder mBuilder) {
|
||||||
|
mBuilder.setPriority(NotificationCompat.PRIORITY_HIGH);
|
||||||
|
setNotificationColor(mBuilder);
|
||||||
|
mBuilder.setLights(LED_COLOR, 2000, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setNotificationColor(final NotificationCompat.Builder mBuilder) {
|
||||||
|
mBuilder.setColor(ContextCompat.getColor(context, R.color.seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void pushMissedCallNow(CallLogEntry message) {}
|
||||||
|
|
||||||
|
private class VibrationRunnable implements Runnable {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
final Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
vibrator.vibrate(CALL_PATTERN, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package im.conversations.android.service;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import androidx.lifecycle.LifecycleService;
|
||||||
|
|
||||||
|
public class RtpSessionService extends LifecycleService {
|
||||||
|
|
||||||
|
public static final String ACTION_DISMISS_CALL = "dismiss_call";
|
||||||
|
public static final String ACTION_END_CALL = "end_call";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||||
|
|
||||||
|
return super.onStartCommand(intent, flags, startId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +1,16 @@
|
||||||
package im.conversations.android.tls;
|
package im.conversations.android.tls;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import im.conversations.android.AbstractAccountService;
|
||||||
import im.conversations.android.database.model.Account;
|
import im.conversations.android.database.model.Account;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import javax.net.ssl.X509TrustManager;
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
|
||||||
public class TrustManager implements X509TrustManager {
|
public class TrustManager extends AbstractAccountService implements X509TrustManager {
|
||||||
|
|
||||||
private final Context context;
|
|
||||||
private final Account account;
|
|
||||||
|
|
||||||
public TrustManager(final Context context, final Account account) {
|
public TrustManager(final Context context, final Account account) {
|
||||||
this.context = context;
|
super(context, account);
|
||||||
this.account = account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
package im.conversations.android.transformer;
|
||||||
|
|
||||||
|
public class CallLogEntry {
|
||||||
|
public void setServerMsgId(String serverMsgId) {}
|
||||||
|
|
||||||
|
public void setCarbon(boolean b) {}
|
||||||
|
|
||||||
|
public void markUnread() {}
|
||||||
|
|
||||||
|
public void setDuration(long duration) {}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package im.conversations.android.transformer;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import im.conversations.android.xml.Namespace;
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import im.conversations.android.xmpp.Entity;
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
import im.conversations.android.xmpp.manager.DiscoManager;
|
import im.conversations.android.xmpp.manager.DiscoManager;
|
||||||
import im.conversations.android.xmpp.model.occupant.OccupantId;
|
import im.conversations.android.xmpp.model.occupant.OccupantId;
|
||||||
|
@ -34,7 +35,8 @@ public class TransformationFactory extends XmppConnection.Delegate {
|
||||||
if (message.getType() == Message.Type.GROUPCHAT && message.hasExtension(OccupantId.class)) {
|
if (message.getType() == Message.Type.GROUPCHAT && message.hasExtension(OccupantId.class)) {
|
||||||
if (from != null
|
if (from != null
|
||||||
&& getManager(DiscoManager.class)
|
&& getManager(DiscoManager.class)
|
||||||
.hasFeature(from.asBareJid(), Namespace.OCCUPANT_ID)) {
|
.hasFeature(
|
||||||
|
Entity.discoItem(from.asBareJid()), Namespace.OCCUPANT_ID)) {
|
||||||
occupantId = message.getExtension(OccupantId.class).getId();
|
occupantId = message.getExtension(OccupantId.class).getId();
|
||||||
} else {
|
} else {
|
||||||
occupantId = null;
|
occupantId = null;
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,50 @@
|
||||||
|
package im.conversations.android.ui.model;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.lifecycle.AndroidViewModel;
|
||||||
|
import com.google.common.util.concurrent.FutureCallback;
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
|
import im.conversations.android.xmpp.ConnectionPool;
|
||||||
|
import im.conversations.android.xmpp.manager.JingleConnectionManager;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public class RtpSessionViewModel extends AndroidViewModel {
|
||||||
|
|
||||||
|
private long accountId;
|
||||||
|
|
||||||
|
public RtpSessionViewModel(@NonNull Application application) {
|
||||||
|
super(application);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccountId(
|
||||||
|
final long accountId, final Consumer<JingleConnectionManager> jmcConsumer) {
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.connectJingleConnectionManager(accountId, jmcConsumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectJingleConnectionManager(
|
||||||
|
long accountId, final Consumer<JingleConnectionManager> jmcConsumer) {
|
||||||
|
final var connectionFuture = ConnectionPool.getInstance(getApplication()).get(accountId);
|
||||||
|
final var jcmFuture =
|
||||||
|
Futures.transform(
|
||||||
|
connectionFuture,
|
||||||
|
connection -> connection.getManager(JingleConnectionManager.class),
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
Futures.addCallback(
|
||||||
|
jcmFuture,
|
||||||
|
new FutureCallback<>() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(JingleConnectionManager manager) {
|
||||||
|
jmcConsumer.accept(manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Throwable t) {
|
||||||
|
// TODO show warning in activity
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
package im.conversations.android.ui.widget;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.util.Rational;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
|
||||||
|
public class SurfaceViewRenderer extends org.webrtc.SurfaceViewRenderer {
|
||||||
|
|
||||||
|
private Rational aspectRatio = new Rational(1, 1);
|
||||||
|
|
||||||
|
private OnAspectRatioChanged onAspectRatioChanged;
|
||||||
|
|
||||||
|
public SurfaceViewRenderer(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SurfaceViewRenderer(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onFrameResolutionChanged(int videoWidth, int videoHeight, int rotation) {
|
||||||
|
super.onFrameResolutionChanged(videoWidth, videoHeight, rotation);
|
||||||
|
final int rotatedWidth = rotation != 0 && rotation != 180 ? videoHeight : videoWidth;
|
||||||
|
final int rotatedHeight = rotation != 0 && rotation != 180 ? videoWidth : videoHeight;
|
||||||
|
final Rational currentRational = this.aspectRatio;
|
||||||
|
this.aspectRatio = new Rational(rotatedWidth, rotatedHeight);
|
||||||
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"onFrameResolutionChanged("
|
||||||
|
+ rotatedWidth
|
||||||
|
+ ","
|
||||||
|
+ rotatedHeight
|
||||||
|
+ ","
|
||||||
|
+ aspectRatio
|
||||||
|
+ ")");
|
||||||
|
if (currentRational.equals(this.aspectRatio) || onAspectRatioChanged == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAspectRatioChanged.onAspectRatioChanged(this.aspectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnAspectRatioChanged(final OnAspectRatioChanged onAspectRatioChanged) {
|
||||||
|
this.onAspectRatioChanged = onAspectRatioChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Rational getAspectRatio() {
|
||||||
|
return this.aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface OnAspectRatioChanged {
|
||||||
|
void onAspectRatioChanged(final Rational rational);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Daniel Gultsch
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.conversations.android.util;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
public class MainThreadExecutor implements Executor {
|
||||||
|
|
||||||
|
private static final MainThreadExecutor INSTANCE = new MainThreadExecutor();
|
||||||
|
|
||||||
|
private final Handler handler = new Handler(Looper.myLooper());
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(final Runnable command) {
|
||||||
|
handler.post(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MainThreadExecutor getInstance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
package im.conversations.android.util;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.primitives.Ints;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class PermissionUtils {
|
||||||
|
|
||||||
|
public static boolean allGranted(int[] grantResults) {
|
||||||
|
for (int grantResult : grantResults) {
|
||||||
|
if (grantResult != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean writeGranted(int[] grantResults, String[] permission) {
|
||||||
|
for (int i = 0; i < grantResults.length; ++i) {
|
||||||
|
if (Manifest.permission.WRITE_EXTERNAL_STORAGE.equals(permission[i])) {
|
||||||
|
return grantResults[i] == PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getFirstDenied(int[] grantResults, String[] permissions) {
|
||||||
|
for (int i = 0; i < grantResults.length; ++i) {
|
||||||
|
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
|
||||||
|
return permissions[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PermissionResult {
|
||||||
|
public final String[] permissions;
|
||||||
|
public final int[] grantResults;
|
||||||
|
|
||||||
|
public PermissionResult(String[] permissions, int[] grantResults) {
|
||||||
|
this.permissions = permissions;
|
||||||
|
this.grantResults = grantResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PermissionResult removeBluetoothConnect(
|
||||||
|
final String[] inPermissions, final int[] inGrantResults) {
|
||||||
|
final List<String> outPermissions = new ArrayList<>();
|
||||||
|
final List<Integer> outGrantResults = new ArrayList<>();
|
||||||
|
for (int i = 0; i < Math.min(inPermissions.length, inGrantResults.length); ++i) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
if (inPermissions[i].equals(Manifest.permission.BLUETOOTH_CONNECT)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outPermissions.add(inPermissions[i]);
|
||||||
|
outGrantResults.add(inGrantResults[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PermissionResult(
|
||||||
|
outPermissions.toArray(new String[0]), Ints.toArray(outGrantResults));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasPermission(
|
||||||
|
final Activity activity, final List<String> permissions, final int requestCode) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
final ImmutableList.Builder<String> missingPermissions = new ImmutableList.Builder<>();
|
||||||
|
for (final String permission : permissions) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(activity, permission)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
missingPermissions.add(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final ImmutableList<String> missing = missingPermissions.build();
|
||||||
|
if (missing.size() == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity, missing.toArray(new String[0]), requestCode);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package im.conversations.android.util;
|
||||||
|
|
||||||
|
import android.util.Rational;
|
||||||
|
|
||||||
|
public final class Rationals {
|
||||||
|
|
||||||
|
// between 2.39:1 and 1:2.39 (inclusive).
|
||||||
|
private static final Rational MIN = new Rational(100, 239);
|
||||||
|
private static final Rational MAX = new Rational(239, 100);
|
||||||
|
|
||||||
|
private Rationals() {}
|
||||||
|
|
||||||
|
public static Rational clip(final Rational input) {
|
||||||
|
if (input.compareTo(MIN) < 0) {
|
||||||
|
return MIN;
|
||||||
|
}
|
||||||
|
if (input.compareTo(MAX) > 0) {
|
||||||
|
return MAX;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2018, Daniel Gultsch All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
* are permitted provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
* list of conditions and the following disclaimer.
|
||||||
|
*
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
* this list of conditions and the following disclaimer in the documentation and/or
|
||||||
|
* other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* 3. Neither the name of the copyright holder nor the names of its contributors
|
||||||
|
* may be used to endorse or promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.conversations.android.util;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import androidx.annotation.PluralsRes;
|
||||||
|
import im.conversations.android.R;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public class TimeFrameUtils {
|
||||||
|
|
||||||
|
private static final TimeFrame[] TIME_FRAMES;
|
||||||
|
|
||||||
|
static {
|
||||||
|
TIME_FRAMES =
|
||||||
|
new TimeFrame[] {
|
||||||
|
new TimeFrame(1000L, R.plurals.seconds),
|
||||||
|
new TimeFrame(60L * 1000, R.plurals.minutes),
|
||||||
|
new TimeFrame(60L * 60 * 1000, R.plurals.hours),
|
||||||
|
new TimeFrame(24L * 60 * 60 * 1000, R.plurals.days),
|
||||||
|
new TimeFrame(7L * 24 * 60 * 60 * 1000, R.plurals.weeks),
|
||||||
|
new TimeFrame(30L * 24 * 60 * 60 * 1000, R.plurals.months),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String resolve(Context context, long timeFrame) {
|
||||||
|
for (int i = TIME_FRAMES.length - 1; i >= 0; --i) {
|
||||||
|
long duration = TIME_FRAMES[i].duration;
|
||||||
|
long threshold = i > 0 ? (TIME_FRAMES[i - 1].duration / 2) : 0;
|
||||||
|
if (timeFrame >= duration - threshold) {
|
||||||
|
int count =
|
||||||
|
(int)
|
||||||
|
(timeFrame / duration
|
||||||
|
+ ((timeFrame % duration) > (duration / 2) ? 1 : 0));
|
||||||
|
return context.getResources().getQuantityString(TIME_FRAMES[i].name, count, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.getResources().getQuantityString(TIME_FRAMES[0].name, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatTimePassed(final long since, final boolean withMilliseconds) {
|
||||||
|
return formatTimePassed(since, SystemClock.elapsedRealtime(), withMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatTimePassed(
|
||||||
|
final long since, final long to, final boolean withMilliseconds) {
|
||||||
|
final long passed = (since < 0) ? 0 : (to - since);
|
||||||
|
return formatElapsedTime(passed, withMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatElapsedTime(final long elapsed, final boolean withMilliseconds) {
|
||||||
|
final int hours = (int) (elapsed / 3600000);
|
||||||
|
final int minutes = (int) (elapsed / 60000) % 60;
|
||||||
|
final int seconds = (int) (elapsed / 1000) % 60;
|
||||||
|
final int milliseconds = (int) (elapsed / 100) % 10;
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format(Locale.ENGLISH, "%d:%02d:%02d", hours, minutes, seconds);
|
||||||
|
} else if (withMilliseconds) {
|
||||||
|
return String.format(Locale.ENGLISH, "%d:%02d.%d", minutes, seconds, milliseconds);
|
||||||
|
} else {
|
||||||
|
return String.format(Locale.ENGLISH, "%d:%02d", minutes, seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TimeFrame {
|
||||||
|
final long duration;
|
||||||
|
public final int name;
|
||||||
|
|
||||||
|
private TimeFrame(long duration, @PluralsRes int name) {
|
||||||
|
this.duration = duration;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -155,6 +155,10 @@ public class Element {
|
||||||
return this.setAttribute(name, value == null ? null : value.toString());
|
return this.setAttribute(name, value == null ? null : value.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setAttribute(final String name, final boolean value) {
|
||||||
|
this.setAttribute(name, value ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
public void removeAttribute(final String name) {
|
public void removeAttribute(final String name) {
|
||||||
this.attributes.remove(name);
|
this.attributes.remove(name);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import im.conversations.android.xmpp.manager.BookmarkManager;
|
||||||
import im.conversations.android.xmpp.manager.CarbonsManager;
|
import im.conversations.android.xmpp.manager.CarbonsManager;
|
||||||
import im.conversations.android.xmpp.manager.ChatStateManager;
|
import im.conversations.android.xmpp.manager.ChatStateManager;
|
||||||
import im.conversations.android.xmpp.manager.DiscoManager;
|
import im.conversations.android.xmpp.manager.DiscoManager;
|
||||||
|
import im.conversations.android.xmpp.manager.ExternalDiscoManager;
|
||||||
import im.conversations.android.xmpp.manager.HttpUploadManager;
|
import im.conversations.android.xmpp.manager.HttpUploadManager;
|
||||||
import im.conversations.android.xmpp.manager.JingleConnectionManager;
|
import im.conversations.android.xmpp.manager.JingleConnectionManager;
|
||||||
import im.conversations.android.xmpp.manager.NickManager;
|
import im.conversations.android.xmpp.manager.NickManager;
|
||||||
|
@ -38,6 +39,7 @@ public final class Managers {
|
||||||
.put(CarbonsManager.class, new CarbonsManager(context, connection))
|
.put(CarbonsManager.class, new CarbonsManager(context, connection))
|
||||||
.put(ChatStateManager.class, new ChatStateManager(context, connection))
|
.put(ChatStateManager.class, new ChatStateManager(context, connection))
|
||||||
.put(DiscoManager.class, new DiscoManager(context, connection))
|
.put(DiscoManager.class, new DiscoManager(context, connection))
|
||||||
|
.put(ExternalDiscoManager.class, new ExternalDiscoManager(context, connection))
|
||||||
.put(HttpUploadManager.class, new HttpUploadManager(context, connection))
|
.put(HttpUploadManager.class, new HttpUploadManager(context, connection))
|
||||||
.put(
|
.put(
|
||||||
JingleConnectionManager.class,
|
JingleConnectionManager.class,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import com.google.common.util.concurrent.MoreExecutors;
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
import com.google.common.util.concurrent.SettableFuture;
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
|
import im.conversations.android.AbstractAccountService;
|
||||||
import im.conversations.android.BuildConfig;
|
import im.conversations.android.BuildConfig;
|
||||||
import im.conversations.android.Conversations;
|
import im.conversations.android.Conversations;
|
||||||
import im.conversations.android.IDs;
|
import im.conversations.android.IDs;
|
||||||
|
@ -105,17 +106,15 @@ import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.xmlpull.v1.XmlPullParserException;
|
import org.xmlpull.v1.XmlPullParserException;
|
||||||
|
|
||||||
public class XmppConnection implements Runnable {
|
public class XmppConnection extends AbstractAccountService implements Runnable {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(XmppConnection.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(XmppConnection.class);
|
||||||
|
|
||||||
private static final boolean EXTENDED_SM_LOGGING = false;
|
private static final boolean EXTENDED_SM_LOGGING = false;
|
||||||
private static final int CONNECT_DISCO_TIMEOUT = 20;
|
private static final int CONNECT_DISCO_TIMEOUT = 20;
|
||||||
|
|
||||||
protected final Account account;
|
|
||||||
private final SparseArray<Stanza> mStanzaQueue = new SparseArray<>();
|
private final SparseArray<Stanza> mStanzaQueue = new SparseArray<>();
|
||||||
private final Hashtable<String, Pair<Iq, Consumer<Iq>>> packetCallbacks = new Hashtable<>();
|
private final Hashtable<String, Pair<Iq, Consumer<Iq>>> packetCallbacks = new Hashtable<>();
|
||||||
private final Context context;
|
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
private XmlReader tagReader;
|
private XmlReader tagReader;
|
||||||
private TagWriter tagWriter = new TagWriter();
|
private TagWriter tagWriter = new TagWriter();
|
||||||
|
@ -156,8 +155,7 @@ public class XmppConnection implements Runnable {
|
||||||
private CountDownLatch mStreamCountDownLatch;
|
private CountDownLatch mStreamCountDownLatch;
|
||||||
|
|
||||||
public XmppConnection(final Context context, final Account account) {
|
public XmppConnection(final Context context, final Account account) {
|
||||||
this.context = context;
|
super(context, account);
|
||||||
this.account = account;
|
|
||||||
this.connectionAddress = account.address;
|
this.connectionAddress = account.address;
|
||||||
|
|
||||||
// these consumers are pure listeners; they don’t have public method except for accept|apply
|
// these consumers are pure listeners; they don’t have public method except for accept|apply
|
||||||
|
|
|
@ -2,23 +2,39 @@ package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.common.util.concurrent.FutureCallback;
|
import com.google.common.util.concurrent.FutureCallback;
|
||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import com.google.common.util.concurrent.MoreExecutors;
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
import im.conversations.android.database.AxolotlDatabaseStore;
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.OmemoVerification;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.OmemoVerifiedRtpContentMap;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.RtpContentMap;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
|
||||||
|
import im.conversations.android.axolotl.AxolotlAddress;
|
||||||
|
import im.conversations.android.axolotl.AxolotlDecryptionException;
|
||||||
|
import im.conversations.android.axolotl.AxolotlEncryptionException;
|
||||||
|
import im.conversations.android.axolotl.AxolotlPayload;
|
||||||
|
import im.conversations.android.axolotl.AxolotlService;
|
||||||
|
import im.conversations.android.axolotl.AxolotlSession;
|
||||||
|
import im.conversations.android.axolotl.EncryptionBuilder;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
import im.conversations.android.xml.Namespace;
|
import im.conversations.android.xml.Namespace;
|
||||||
import im.conversations.android.xmpp.IqErrorException;
|
import im.conversations.android.xmpp.IqErrorException;
|
||||||
import im.conversations.android.xmpp.NodeConfiguration;
|
import im.conversations.android.xmpp.NodeConfiguration;
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
import im.conversations.android.xmpp.axolotl.AxolotlAddress;
|
|
||||||
import im.conversations.android.xmpp.model.axolotl.Bundle;
|
import im.conversations.android.xmpp.model.axolotl.Bundle;
|
||||||
import im.conversations.android.xmpp.model.axolotl.DeviceList;
|
import im.conversations.android.xmpp.model.axolotl.DeviceList;
|
||||||
|
import im.conversations.android.xmpp.model.axolotl.Encrypted;
|
||||||
import im.conversations.android.xmpp.model.pubsub.Items;
|
import im.conversations.android.xmpp.model.pubsub.Items;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
import org.jxmpp.jid.BareJid;
|
import org.jxmpp.jid.BareJid;
|
||||||
|
@ -28,7 +44,6 @@ import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.libsignal.IdentityKey;
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
import org.whispersystems.libsignal.InvalidKeyException;
|
import org.whispersystems.libsignal.InvalidKeyException;
|
||||||
import org.whispersystems.libsignal.SessionBuilder;
|
import org.whispersystems.libsignal.SessionBuilder;
|
||||||
import org.whispersystems.libsignal.SessionCipher;
|
|
||||||
import org.whispersystems.libsignal.UntrustedIdentityException;
|
import org.whispersystems.libsignal.UntrustedIdentityException;
|
||||||
import org.whispersystems.libsignal.state.PreKeyBundle;
|
import org.whispersystems.libsignal.state.PreKeyBundle;
|
||||||
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
||||||
|
@ -41,11 +56,11 @@ public class AxolotlManager extends AbstractManager {
|
||||||
|
|
||||||
private static final int NUM_PRE_KEYS_IN_BUNDLE = 30;
|
private static final int NUM_PRE_KEYS_IN_BUNDLE = 30;
|
||||||
|
|
||||||
private final SignalProtocolStore signalProtocolStore;
|
private final AxolotlService axolotlService;
|
||||||
|
|
||||||
public AxolotlManager(Context context, XmppConnection connection) {
|
public AxolotlManager(Context context, XmppConnection connection) {
|
||||||
super(context, connection);
|
super(context, connection);
|
||||||
this.signalProtocolStore = new AxolotlDatabaseStore(context, connection.getAccount());
|
this.axolotlService = new AxolotlService(context, connection.getAccount());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void handleItems(final BareJid from, final Items items) {
|
public void handleItems(final BareJid from, final Items items) {
|
||||||
|
@ -103,25 +118,27 @@ public class AxolotlManager extends AbstractManager {
|
||||||
return getManager(PubSubManager.class).fetchMostRecentItem(address, node, Bundle.class);
|
return getManager(PubSubManager.class).fetchMostRecentItem(address, node, Bundle.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ListenableFuture<SessionCipher> getOrCreateSessionCipher(
|
public ListenableFuture<AxolotlSession> getOrCreateSessionCipher(
|
||||||
final AxolotlAddress axolotlAddress) {
|
final AxolotlAddress axolotlAddress) {
|
||||||
if (signalProtocolStore.containsSession(axolotlAddress)) {
|
final AxolotlSession session = axolotlService.getExistingSession(axolotlAddress);
|
||||||
return Futures.immediateFuture(new SessionCipher(signalProtocolStore, axolotlAddress));
|
if (session != null) {
|
||||||
|
return Futures.immediateFuture(session);
|
||||||
} else {
|
} else {
|
||||||
final var bundleFuture =
|
final var bundleFuture =
|
||||||
fetchBundle(axolotlAddress.getJid(), axolotlAddress.getDeviceId());
|
fetchBundle(axolotlAddress.getJid(), axolotlAddress.getDeviceId());
|
||||||
return Futures.transform(
|
return Futures.transform(
|
||||||
bundleFuture,
|
bundleFuture,
|
||||||
bundle -> {
|
bundle -> {
|
||||||
buildSession(axolotlAddress, bundle);
|
final var identityKey = buildSession(axolotlAddress, bundle);
|
||||||
return new SessionCipher(signalProtocolStore, axolotlAddress);
|
return AxolotlSession.of(
|
||||||
|
signalProtocolStore(), identityKey, axolotlAddress);
|
||||||
},
|
},
|
||||||
MoreExecutors.directExecutor());
|
MoreExecutors.directExecutor());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildSession(final AxolotlAddress address, final Bundle bundle) {
|
private IdentityKey buildSession(final AxolotlAddress address, final Bundle bundle) {
|
||||||
final var sessionBuilder = new SessionBuilder(signalProtocolStore, address);
|
final var sessionBuilder = new SessionBuilder(signalProtocolStore(), address);
|
||||||
final var deviceId = address.getDeviceId();
|
final var deviceId = address.getDeviceId();
|
||||||
final var preKey = bundle.getRandomPreKey();
|
final var preKey = bundle.getRandomPreKey();
|
||||||
final var signedPreKey = bundle.getSignedPreKey();
|
final var signedPreKey = bundle.getSignedPreKey();
|
||||||
|
@ -139,6 +156,7 @@ public class AxolotlManager extends AbstractManager {
|
||||||
if (identityKey == null) {
|
if (identityKey == null) {
|
||||||
throw new IllegalArgumentException("No IdentityKey found in bundle");
|
throw new IllegalArgumentException("No IdentityKey found in bundle");
|
||||||
}
|
}
|
||||||
|
final var signalIdentityKey = new IdentityKey(identityKey.asECPublicKey());
|
||||||
final var preKeyBundle =
|
final var preKeyBundle =
|
||||||
new PreKeyBundle(
|
new PreKeyBundle(
|
||||||
0,
|
0,
|
||||||
|
@ -148,9 +166,10 @@ public class AxolotlManager extends AbstractManager {
|
||||||
signedPreKey.getId(),
|
signedPreKey.getId(),
|
||||||
signedPreKey.asECPublicKey(),
|
signedPreKey.asECPublicKey(),
|
||||||
signedPreKeySignature.asBytes(),
|
signedPreKeySignature.asBytes(),
|
||||||
new IdentityKey(identityKey.asECPublicKey()));
|
signalIdentityKey);
|
||||||
try {
|
try {
|
||||||
sessionBuilder.process(preKeyBundle);
|
sessionBuilder.process(preKeyBundle);
|
||||||
|
return signalIdentityKey;
|
||||||
} catch (final InvalidKeyException | UntrustedIdentityException e) {
|
} catch (final InvalidKeyException | UntrustedIdentityException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
@ -249,7 +268,7 @@ public class AxolotlManager extends AbstractManager {
|
||||||
Locale.ROOT,
|
Locale.ROOT,
|
||||||
"%s:%d",
|
"%s:%d",
|
||||||
Namespace.AXOLOTL_BUNDLES,
|
Namespace.AXOLOTL_BUNDLES,
|
||||||
signalProtocolStore.getLocalRegistrationId());
|
signalProtocolStore().getLocalRegistrationId());
|
||||||
return getManager(PepManager.class)
|
return getManager(PepManager.class)
|
||||||
.publishSingleton(bundle, node, NodeConfiguration.OPEN);
|
.publishSingleton(bundle, node, NodeConfiguration.OPEN);
|
||||||
},
|
},
|
||||||
|
@ -260,7 +279,7 @@ public class AxolotlManager extends AbstractManager {
|
||||||
refillPreKeys();
|
refillPreKeys();
|
||||||
final var bundle = new Bundle();
|
final var bundle = new Bundle();
|
||||||
bundle.setIdentityKey(
|
bundle.setIdentityKey(
|
||||||
signalProtocolStore.getIdentityKeyPair().getPublicKey().getPublicKey());
|
signalProtocolStore().getIdentityKeyPair().getPublicKey().getPublicKey());
|
||||||
final var signedPreKeyRecord =
|
final var signedPreKeyRecord =
|
||||||
getDatabase().axolotlDao().getLatestSignedPreKey(getAccount().id);
|
getDatabase().axolotlDao().getLatestSignedPreKey(getAccount().id);
|
||||||
if (signedPreKeyRecord == null) {
|
if (signedPreKeyRecord == null) {
|
||||||
|
@ -286,11 +305,11 @@ public class AxolotlManager extends AbstractManager {
|
||||||
try {
|
try {
|
||||||
signedPreKeyRecord =
|
signedPreKeyRecord =
|
||||||
KeyHelper.generateSignedPreKey(
|
KeyHelper.generateSignedPreKey(
|
||||||
signalProtocolStore.getIdentityKeyPair(), signedPreKeyId);
|
signalProtocolStore().getIdentityKeyPair(), signedPreKeyId);
|
||||||
} catch (final InvalidKeyException e) {
|
} catch (final InvalidKeyException e) {
|
||||||
throw new IllegalStateException("Could not generate SignedPreKey", e);
|
throw new IllegalStateException("Could not generate SignedPreKey", e);
|
||||||
}
|
}
|
||||||
signalProtocolStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord);
|
signalProtocolStore().storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord);
|
||||||
LOGGER.info("Generated SignedPreKey #{}", signedPreKeyRecord.getId());
|
LOGGER.info("Generated SignedPreKey #{}", signedPreKeyRecord.getId());
|
||||||
}
|
}
|
||||||
axolotlDao.setPreKeys(getAccount(), preKeys);
|
axolotlDao.setPreKeys(getAccount(), preKeys);
|
||||||
|
@ -298,4 +317,165 @@ public class AxolotlManager extends AbstractManager {
|
||||||
LOGGER.info("Generated {} PreKeys starting with {}", preKeys.size(), start);
|
LOGGER.info("Generated {} PreKeys starting with {}", preKeys.size(), start);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private OmemoVerifiedIceUdpTransportInfo encrypt(
|
||||||
|
final IceUdpTransportInfo element, final AxolotlSession session)
|
||||||
|
throws AxolotlEncryptionException {
|
||||||
|
final OmemoVerifiedIceUdpTransportInfo transportInfo =
|
||||||
|
new OmemoVerifiedIceUdpTransportInfo();
|
||||||
|
transportInfo.setAttributes(element.getAttributes());
|
||||||
|
for (final Element child : element.getChildren()) {
|
||||||
|
if ("fingerprint".equals(child.getName())
|
||||||
|
&& Namespace.JINGLE_APPS_DTLS.equals(child.getNamespace())) {
|
||||||
|
final Element fingerprint =
|
||||||
|
new Element("fingerprint", Namespace.OMEMO_DTLS_SRTP_VERIFICATION);
|
||||||
|
fingerprint.setAttribute("setup", child.getAttribute("setup"));
|
||||||
|
fingerprint.setAttribute("hash", child.getAttribute("hash"));
|
||||||
|
final String content = child.getContent();
|
||||||
|
final var encrypted =
|
||||||
|
new EncryptionBuilder()
|
||||||
|
.sourceDeviceId(signalProtocolStore().getLocalRegistrationId())
|
||||||
|
.payload(content)
|
||||||
|
.session(session)
|
||||||
|
.build();
|
||||||
|
fingerprint.addExtension(encrypted);
|
||||||
|
transportInfo.addChild(fingerprint);
|
||||||
|
} else {
|
||||||
|
transportInfo.addChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transportInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
|
||||||
|
encrypt(final RtpContentMap rtpContentMap, final Jid jid, final int deviceId) {
|
||||||
|
final var axolotlAddress = new AxolotlAddress(jid.asBareJid(), deviceId);
|
||||||
|
final var sessionFuture = getOrCreateSessionCipher(axolotlAddress);
|
||||||
|
return Futures.transformAsync(
|
||||||
|
sessionFuture,
|
||||||
|
session -> encrypt(rtpContentMap, session),
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ListenableFuture<AxolotlService.OmemoVerifiedPayload<OmemoVerifiedRtpContentMap>>
|
||||||
|
encrypt(final RtpContentMap rtpContentMap, final AxolotlSession session) {
|
||||||
|
if (Config.REQUIRE_RTP_VERIFICATION) {
|
||||||
|
requireVerification(session);
|
||||||
|
}
|
||||||
|
final ImmutableMap.Builder<String, RtpContentMap.DescriptionTransport>
|
||||||
|
descriptionTransportBuilder = new ImmutableMap.Builder<>();
|
||||||
|
final OmemoVerification omemoVerification = new OmemoVerification();
|
||||||
|
omemoVerification.setDeviceId(session.axolotlAddress.getDeviceId());
|
||||||
|
omemoVerification.setSessionFingerprint(session.identityKey);
|
||||||
|
for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content :
|
||||||
|
rtpContentMap.contents.entrySet()) {
|
||||||
|
final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue();
|
||||||
|
final OmemoVerifiedIceUdpTransportInfo encryptedTransportInfo;
|
||||||
|
try {
|
||||||
|
encryptedTransportInfo = encrypt(descriptionTransport.transport, session);
|
||||||
|
} catch (final AxolotlEncryptionException e) {
|
||||||
|
return Futures.immediateFailedFuture(e);
|
||||||
|
}
|
||||||
|
descriptionTransportBuilder.put(
|
||||||
|
content.getKey(),
|
||||||
|
new RtpContentMap.DescriptionTransport(
|
||||||
|
descriptionTransport.senders,
|
||||||
|
descriptionTransport.description,
|
||||||
|
encryptedTransportInfo));
|
||||||
|
}
|
||||||
|
return Futures.immediateFuture(
|
||||||
|
new AxolotlService.OmemoVerifiedPayload<>(
|
||||||
|
omemoVerification,
|
||||||
|
new OmemoVerifiedRtpContentMap(
|
||||||
|
rtpContentMap.group, descriptionTransportBuilder.build())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<AxolotlService.OmemoVerifiedPayload<RtpContentMap>> decrypt(
|
||||||
|
OmemoVerifiedRtpContentMap omemoVerifiedRtpContentMap, final Jid from) {
|
||||||
|
final ImmutableMap.Builder<String, RtpContentMap.DescriptionTransport>
|
||||||
|
descriptionTransportBuilder = new ImmutableMap.Builder<>();
|
||||||
|
final OmemoVerification omemoVerification = new OmemoVerification();
|
||||||
|
final ImmutableList.Builder<ListenableFuture<AxolotlSession>> pepVerificationFutures =
|
||||||
|
new ImmutableList.Builder<>();
|
||||||
|
for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content :
|
||||||
|
omemoVerifiedRtpContentMap.contents.entrySet()) {
|
||||||
|
final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue();
|
||||||
|
final AxolotlService.OmemoVerifiedPayload<IceUdpTransportInfo> decryptedTransport;
|
||||||
|
try {
|
||||||
|
decryptedTransport =
|
||||||
|
decrypt(
|
||||||
|
(OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport,
|
||||||
|
from,
|
||||||
|
pepVerificationFutures);
|
||||||
|
} catch (final AxolotlDecryptionException e) {
|
||||||
|
return Futures.immediateFailedFuture(e);
|
||||||
|
}
|
||||||
|
omemoVerification.setOrEnsureEqual(decryptedTransport);
|
||||||
|
descriptionTransportBuilder.put(
|
||||||
|
content.getKey(),
|
||||||
|
new RtpContentMap.DescriptionTransport(
|
||||||
|
descriptionTransport.senders,
|
||||||
|
descriptionTransport.description,
|
||||||
|
decryptedTransport.getPayload()));
|
||||||
|
}
|
||||||
|
final ImmutableList<ListenableFuture<AxolotlSession>> sessionFutures =
|
||||||
|
pepVerificationFutures.build();
|
||||||
|
return Futures.transform(
|
||||||
|
Futures.allAsList(sessionFutures),
|
||||||
|
sessions -> {
|
||||||
|
if (Config.REQUIRE_RTP_VERIFICATION) {
|
||||||
|
for (final AxolotlSession session : sessions) {
|
||||||
|
requireVerification(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AxolotlService.OmemoVerifiedPayload<>(
|
||||||
|
omemoVerification,
|
||||||
|
new RtpContentMap(
|
||||||
|
omemoVerifiedRtpContentMap.group,
|
||||||
|
descriptionTransportBuilder.build()));
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AxolotlService.OmemoVerifiedPayload<IceUdpTransportInfo> decrypt(
|
||||||
|
final OmemoVerifiedIceUdpTransportInfo verifiedIceUdpTransportInfo,
|
||||||
|
final Jid from,
|
||||||
|
ImmutableList.Builder<ListenableFuture<AxolotlSession>> pepVerificationFutures)
|
||||||
|
throws AxolotlDecryptionException {
|
||||||
|
final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo();
|
||||||
|
transportInfo.setAttributes(verifiedIceUdpTransportInfo.getAttributes());
|
||||||
|
final OmemoVerification omemoVerification = new OmemoVerification();
|
||||||
|
for (final Element child : verifiedIceUdpTransportInfo.getChildren()) {
|
||||||
|
if ("fingerprint".equals(child.getName())
|
||||||
|
&& Namespace.OMEMO_DTLS_SRTP_VERIFICATION.equals(child.getNamespace())) {
|
||||||
|
final Element fingerprint = new Element("fingerprint", Namespace.JINGLE_APPS_DTLS);
|
||||||
|
fingerprint.setAttribute("setup", child.getAttribute("setup"));
|
||||||
|
fingerprint.setAttribute("hash", child.getAttribute("hash"));
|
||||||
|
final Encrypted encrypted = child.getExtension(Encrypted.class);
|
||||||
|
final AxolotlPayload axolotlPayload = axolotlService.decrypt(from, encrypted);
|
||||||
|
fingerprint.setContent(axolotlPayload.payloadAsString());
|
||||||
|
omemoVerification.setDeviceId(axolotlPayload.axolotlAddress.getDeviceId());
|
||||||
|
omemoVerification.setSessionFingerprint(axolotlPayload.identityKey);
|
||||||
|
transportInfo.addChild(fingerprint);
|
||||||
|
} else {
|
||||||
|
transportInfo.addChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AxolotlService.OmemoVerifiedPayload<>(omemoVerification, transportInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireVerification(final AxolotlSession session) {
|
||||||
|
// TODO fix me; check if identity key is trusted
|
||||||
|
|
||||||
|
/*if (session.getTrust().isVerified()) {
|
||||||
|
return;
|
||||||
|
}*/
|
||||||
|
throw new AxolotlService.NotVerifiedException(
|
||||||
|
String.format(
|
||||||
|
"session with %s was not verified", session.identityKey.getFingerprint()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SignalProtocolStore signalProtocolStore() {
|
||||||
|
return this.axolotlService.getSignalProtocolStore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,6 @@ import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import org.jxmpp.jid.Jid;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -67,8 +66,8 @@ public class DiscoManager extends AbstractManager {
|
||||||
Namespace.JINGLE_FEATURE_AUDIO,
|
Namespace.JINGLE_FEATURE_AUDIO,
|
||||||
Namespace.JINGLE_FEATURE_VIDEO,
|
Namespace.JINGLE_FEATURE_VIDEO,
|
||||||
Namespace.JINGLE_APPS_RTP,
|
Namespace.JINGLE_APPS_RTP,
|
||||||
Namespace.JINGLE_APPS_DTLS,
|
Namespace.JINGLE_APPS_DTLS /*,
|
||||||
Namespace.JINGLE_MESSAGE);
|
Namespace.JINGLE_MESSAGE*/);
|
||||||
|
|
||||||
private static final Collection<String> FEATURES_IMPACTING_PRIVACY =
|
private static final Collection<String> FEATURES_IMPACTING_PRIVACY =
|
||||||
Collections.singleton(Namespace.VERSION);
|
Collections.singleton(Namespace.VERSION);
|
||||||
|
@ -241,16 +240,28 @@ public class DiscoManager extends AbstractManager {
|
||||||
MoreExecutors.directExecutor());
|
MoreExecutors.directExecutor());
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasFeature(final Jid entity, final String feature) {
|
public boolean hasFeature(final Entity entity, final String feature) {
|
||||||
return getDatabase().discoDao().hasFeature(getAccount().id, entity, feature);
|
return getDatabase().discoDao().hasFeature(getAccount().id, entity, feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<Boolean> hasFeatureAsync(final Entity entity, final String feature) {
|
||||||
|
return Futures.submit(() -> hasFeature(entity, feature), getDatabase().getQueryExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
public boolean hasAccountFeature(final String feature) {
|
public boolean hasAccountFeature(final String feature) {
|
||||||
return hasFeature(getAccount().address, feature);
|
return hasFeature(Entity.discoItem(getAccount().address), feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<Boolean> hasAccountFeatureAsync(final String feature) {
|
||||||
|
return Futures.submit(() -> hasAccountFeature(feature), getDatabase().getQueryExecutor());
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasServerFeature(final String feature) {
|
public boolean hasServerFeature(final String feature) {
|
||||||
return hasFeature(getAccount().address.asDomainBareJid(), feature);
|
return hasFeature(Entity.discoItem(getAccount().address.asDomainBareJid()), feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<Boolean> hasServerFeatureAsync(final String feature) {
|
||||||
|
return Futures.submit(() -> hasServerFeature(feature), getDatabase().getQueryExecutor());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceDescription getServiceDescription() {
|
public ServiceDescription getServiceDescription() {
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
import im.conversations.android.xmpp.model.disco.external.Service;
|
||||||
|
import im.conversations.android.xmpp.model.disco.external.Services;
|
||||||
|
import im.conversations.android.xmpp.model.stanza.Iq;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
public class ExternalDiscoManager extends AbstractManager {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(ExternalDiscoManager.class);
|
||||||
|
|
||||||
|
public ExternalDiscoManager(Context context, XmppConnection connection) {
|
||||||
|
super(context, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListenableFuture<Collection<Service>> getServices() {
|
||||||
|
final var hasFeatureFuture =
|
||||||
|
getManager(DiscoManager.class)
|
||||||
|
.hasServerFeatureAsync(Namespace.EXTERNAL_SERVICE_DISCOVERY);
|
||||||
|
final var iqResultFuture =
|
||||||
|
Futures.transformAsync(
|
||||||
|
hasFeatureFuture,
|
||||||
|
hasFeature -> {
|
||||||
|
if (Boolean.TRUE.equals(hasFeature)) {
|
||||||
|
final Iq request = new Iq(Iq.Type.GET);
|
||||||
|
request.setTo(getAccount().address.asDomainBareJid());
|
||||||
|
request.addExtension(new Services());
|
||||||
|
return connection.sendIqPacket(request);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Server does not support External Service Discovery");
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
return Futures.transform(
|
||||||
|
iqResultFuture,
|
||||||
|
result -> {
|
||||||
|
final var services = result.getExtension(Services.class);
|
||||||
|
if (services == null) {
|
||||||
|
throw new IllegalStateException("Server result did not contain services");
|
||||||
|
}
|
||||||
|
return services.getExtensions(Service.class);
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,16 +1,880 @@
|
||||||
package im.conversations.android.xmpp.manager;
|
package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.google.common.base.Objects;
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.cache.Cache;
|
||||||
|
import com.google.common.cache.CacheBuilder;
|
||||||
|
import com.google.common.collect.Collections2;
|
||||||
|
import com.google.common.collect.ComparisonChain;
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.generator.MessageGenerator;
|
||||||
|
import eu.siacs.conversations.services.AppRTCAudioManager;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.Media;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.ToneManager;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||||
|
import im.conversations.android.IDs;
|
||||||
|
import im.conversations.android.database.model.Account;
|
||||||
|
import im.conversations.android.notification.RtpSessionNotification;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
import im.conversations.android.xmpp.model.error.Condition;
|
||||||
|
import im.conversations.android.xmpp.model.error.Error;
|
||||||
|
import im.conversations.android.xmpp.model.jmi.Accept;
|
||||||
|
import im.conversations.android.xmpp.model.jmi.JingleMessage;
|
||||||
|
import im.conversations.android.xmpp.model.jmi.Proceed;
|
||||||
|
import im.conversations.android.xmpp.model.jmi.Propose;
|
||||||
import im.conversations.android.xmpp.model.stanza.Iq;
|
import im.conversations.android.xmpp.model.stanza.Iq;
|
||||||
|
import im.conversations.android.xmpp.model.stanza.Message;
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
public class JingleConnectionManager extends AbstractManager {
|
public class JingleConnectionManager extends AbstractManager {
|
||||||
public JingleConnectionManager(Context context, XmppConnection connection) {
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(JingleConnectionManager.class);
|
||||||
|
|
||||||
|
public static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE =
|
||||||
|
Executors.newSingleThreadScheduledExecutor();
|
||||||
|
private final HashMap<RtpSessionProposal, DeviceDiscoveryState> rtpSessionProposals =
|
||||||
|
new HashMap<>();
|
||||||
|
private final ConcurrentHashMap<AbstractJingleConnection.Id, AbstractJingleConnection>
|
||||||
|
connections = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private final Cache<PersistableSessionId, TerminatedRtpSession> terminatedSessions =
|
||||||
|
CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build();
|
||||||
|
|
||||||
|
private final RtpSessionNotification rtpSessionNotification;
|
||||||
|
|
||||||
|
private OnJingleRtpConnectionUpdate onJingleRtpConnectionUpdate;
|
||||||
|
|
||||||
|
public JingleConnectionManager(final Context context, final XmppConnection connection) {
|
||||||
super(context, connection);
|
super(context, connection);
|
||||||
|
this.rtpSessionNotification = new RtpSessionNotification(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void handleJingle(Iq packet) {
|
@Override
|
||||||
|
public Account getAccount() {
|
||||||
|
return super.getAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handleJingle(final Iq iq) {
|
||||||
|
final JinglePacket packet = JinglePacket.upgrade(iq);
|
||||||
|
final String sessionId = packet.getSessionId();
|
||||||
|
if (sessionId == null) {
|
||||||
|
respondWithJingleError(
|
||||||
|
iq, "unknown-session", Error.Type.CANCEL, new Condition.ItemNotFound());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(packet);
|
||||||
|
final AbstractJingleConnection existingJingleConnection = connections.get(id);
|
||||||
|
if (existingJingleConnection != null) {
|
||||||
|
existingJingleConnection.deliverPacket(packet);
|
||||||
|
} else if (packet.getAction() == JinglePacket.Action.SESSION_INITIATE) {
|
||||||
|
final Jid from = packet.getFrom();
|
||||||
|
final Content content = packet.getJingleContent();
|
||||||
|
final String descriptionNamespace =
|
||||||
|
content == null ? null : content.getDescriptionNamespace();
|
||||||
|
final AbstractJingleConnection connection;
|
||||||
|
if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && isUsingClearNet()) {
|
||||||
|
final boolean sessionEnded =
|
||||||
|
this.terminatedSessions.asMap().containsKey(PersistableSessionId.of(id));
|
||||||
|
final boolean stranger = isWithStrangerAndStrangerNotificationsAreOff(id.with);
|
||||||
|
if (isBusy() || sessionEnded || stranger) {
|
||||||
|
LOGGER.debug(
|
||||||
|
this.connection.getAccount().address
|
||||||
|
+ ": rejected session with "
|
||||||
|
+ id.with
|
||||||
|
+ " because busy. sessionEnded="
|
||||||
|
+ sessionEnded
|
||||||
|
+ ", stranger="
|
||||||
|
+ stranger);
|
||||||
|
this.connection.sendResultFor(packet);
|
||||||
|
final JinglePacket sessionTermination =
|
||||||
|
new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
|
||||||
|
sessionTermination.setTo(id.with);
|
||||||
|
sessionTermination.setReason(Reason.BUSY, null);
|
||||||
|
this.connection.sendIqPacket(sessionTermination, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection = new JingleRtpConnection(context, this.connection, id, from);
|
||||||
|
} else {
|
||||||
|
respondWithJingleError(
|
||||||
|
packet,
|
||||||
|
"unsupported-info",
|
||||||
|
Error.Type.CANCEL,
|
||||||
|
new Condition.FeatureNotImplemented());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connections.put(id, connection);
|
||||||
|
connection.deliverPacket(packet);
|
||||||
|
} else {
|
||||||
|
Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
|
||||||
|
respondWithJingleError(
|
||||||
|
packet, "unknown-session", Error.Type.CANCEL, new Condition.ItemNotFound());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isUsingClearNet() {
|
||||||
|
// todo bring back proper Tor check
|
||||||
|
return !connection.getAccount().isOnion();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBusy() {
|
||||||
|
// TODO check if in actual phone call
|
||||||
|
for (AbstractJingleConnection connection : this.connections.values()) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
if (((JingleRtpConnection) connection).isTerminated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
return this.rtpSessionProposals.containsValue(DeviceDiscoveryState.DISCOVERED)
|
||||||
|
|| this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING)
|
||||||
|
|| this.rtpSessionProposals.containsValue(
|
||||||
|
JingleConnectionManager.DeviceDiscoveryState.SEARCHING_ACKNOWLEDGED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyPhoneCallStarted() {
|
||||||
|
for (AbstractJingleConnection connection : connections.values()) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
|
||||||
|
if (rtpConnection.isTerminated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rtpConnection.notifyPhoneCall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<RtpSessionProposal> findMatchingSessionProposal(
|
||||||
|
final Jid with, final Set<Media> media) {
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
|
||||||
|
this.rtpSessionProposals.entrySet()) {
|
||||||
|
final RtpSessionProposal proposal = entry.getKey();
|
||||||
|
final DeviceDiscoveryState state = entry.getValue();
|
||||||
|
final boolean openProposal =
|
||||||
|
state == DeviceDiscoveryState.DISCOVERED
|
||||||
|
|| state == DeviceDiscoveryState.SEARCHING
|
||||||
|
|| state == DeviceDiscoveryState.SEARCHING_ACKNOWLEDGED;
|
||||||
|
if (openProposal
|
||||||
|
&& proposal.with.equals(with.asBareJid())
|
||||||
|
&& proposal.media.equals(media)) {
|
||||||
|
return Optional.of(proposal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.absent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasMatchingRtpSession(final Jid with, final Set<Media> media) {
|
||||||
|
for (AbstractJingleConnection connection : this.connections.values()) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
|
||||||
|
if (rtpConnection.isTerminated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rtpConnection.getId().with.asBareJid().equals(with.asBareJid())
|
||||||
|
&& rtpConnection.getMedia().equals(media)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isWithStrangerAndStrangerNotificationsAreOff(Jid with) {
|
||||||
|
final boolean notifyForStrangers = rtpSessionNotification.notificationsFromStrangers();
|
||||||
|
if (notifyForStrangers) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getDatabase().rosterDao().isInRoster(getAccount().id, with.asBareJid());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScheduledFuture<?> schedule(
|
||||||
|
final Runnable runnable, final long delay, final TimeUnit timeUnit) {
|
||||||
|
return SCHEDULED_EXECUTOR_SERVICE.schedule(runnable, delay, timeUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void respondWithJingleError(
|
||||||
|
final Iq original, String jingleCondition, final Error.Type type, Condition condition) {
|
||||||
|
// TODO add jingle condation
|
||||||
|
connection.sendErrorFor(original, type, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deliverMessage(
|
||||||
|
final Jid to,
|
||||||
|
final Jid from,
|
||||||
|
final JingleMessage message,
|
||||||
|
final String remoteMsgId,
|
||||||
|
final String serverMsgId) {
|
||||||
|
final String sessionId = message.getSessionId();
|
||||||
|
if (Strings.isNullOrEmpty(sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message instanceof Accept) {
|
||||||
|
for (AbstractJingleConnection connection : connections.values()) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
|
||||||
|
final AbstractJingleConnection.Id id = connection.getId();
|
||||||
|
if (id.sessionId.equals(sessionId)) {
|
||||||
|
rtpConnection.deliveryMessage(from, message, serverMsgId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean fromSelf = from.asBareJid().equals(connection.getBoundAddress().asBareJid());
|
||||||
|
final boolean addressedDirectly = to != null && to.equals(connection.getBoundAddress());
|
||||||
|
final AbstractJingleConnection.Id id;
|
||||||
|
if (fromSelf) {
|
||||||
|
if (to != null && to.hasResource()) {
|
||||||
|
id = AbstractJingleConnection.Id.of(to, sessionId);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id = AbstractJingleConnection.Id.of(from, sessionId);
|
||||||
|
}
|
||||||
|
final AbstractJingleConnection existingJingleConnection = connections.get(id);
|
||||||
|
if (existingJingleConnection != null) {
|
||||||
|
if (existingJingleConnection instanceof JingleRtpConnection) {
|
||||||
|
((JingleRtpConnection) existingJingleConnection)
|
||||||
|
.deliveryMessage(from, message, serverMsgId);
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": "
|
||||||
|
+ existingJingleConnection.getClass().getName()
|
||||||
|
+ " does not support jingle messages");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromSelf) {
|
||||||
|
if (message instanceof Proceed) {
|
||||||
|
|
||||||
|
// if we've previously rejected a call because we were busy (which would have
|
||||||
|
// created a CallLogEntry) but that call was picked up on another one of our devices
|
||||||
|
// we want to update that CallLogEntry to say picked up (not missed)
|
||||||
|
|
||||||
|
/*final Conversation c =
|
||||||
|
mXmppConnectionService.findOrCreateConversation(
|
||||||
|
account, id.with, false, false);
|
||||||
|
final Message previousBusy = c.findRtpSession(sessionId, Message.STATUS_RECEIVED);
|
||||||
|
if (previousBusy != null) {
|
||||||
|
previousBusy.setBody(new RtpSessionStatus(true, 0).toString());
|
||||||
|
if (serverMsgId != null) {
|
||||||
|
previousBusy.setServerMsgId(serverMsgId);
|
||||||
|
}
|
||||||
|
previousBusy.setTime(timestamp);
|
||||||
|
mXmppConnectionService.updateMessage(previousBusy, true);
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": updated previous busy because call got picked up by another device");
|
||||||
|
return;
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
// TODO handle reject for cases where we don’t have carbon copies (normally reject is to
|
||||||
|
// be sent to own bare jid as well)
|
||||||
|
LOGGER.debug(connection.getAccount().address + ": ignore jingle message from self");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message instanceof Propose) {
|
||||||
|
final Propose propose = (Propose) message;
|
||||||
|
final List<GenericDescription> descriptions = propose.getDescriptions();
|
||||||
|
final Collection<RtpDescription> rtpDescriptions =
|
||||||
|
Collections2.transform(
|
||||||
|
Collections2.filter(descriptions, d -> d instanceof RtpDescription),
|
||||||
|
input -> (RtpDescription) input);
|
||||||
|
if (rtpDescriptions.size() > 0
|
||||||
|
&& rtpDescriptions.size() == descriptions.size()
|
||||||
|
&& isUsingClearNet()) {
|
||||||
|
final Collection<Media> media =
|
||||||
|
Collections2.transform(rtpDescriptions, RtpDescription::getMedia);
|
||||||
|
if (media.contains(Media.UNKNOWN)) {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": encountered unknown media in session proposal. "
|
||||||
|
+ propose);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Optional<RtpSessionProposal> matchingSessionProposal =
|
||||||
|
findMatchingSessionProposal(id.with, ImmutableSet.copyOf(media));
|
||||||
|
if (matchingSessionProposal.isPresent()) {
|
||||||
|
final String ourSessionId = matchingSessionProposal.get().sessionId;
|
||||||
|
final String theirSessionId = id.sessionId;
|
||||||
|
if (ComparisonChain.start()
|
||||||
|
.compare(ourSessionId, theirSessionId)
|
||||||
|
.compare(
|
||||||
|
connection.getBoundAddress().toString(),
|
||||||
|
id.with.toString())
|
||||||
|
.result()
|
||||||
|
> 0) {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": our session lost tie break. automatically accepting"
|
||||||
|
+ " their session. winning Session="
|
||||||
|
+ theirSessionId);
|
||||||
|
// TODO a retract for this reason should probably include some indication of
|
||||||
|
// tie break
|
||||||
|
retractSessionProposal(matchingSessionProposal.get());
|
||||||
|
final JingleRtpConnection rtpConnection =
|
||||||
|
new JingleRtpConnection(context, this.connection, id, from);
|
||||||
|
this.connections.put(id, rtpConnection);
|
||||||
|
rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
|
||||||
|
rtpConnection.deliveryMessage(from, message, serverMsgId);
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": our session won tie break. waiting for other party to"
|
||||||
|
+ " accept. winningSession="
|
||||||
|
+ ourSessionId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean stranger = isWithStrangerAndStrangerNotificationsAreOff(id.with);
|
||||||
|
if (isBusy() || stranger) {
|
||||||
|
writeLogMissedIncoming(id.with.asBareJid(), id.sessionId, serverMsgId);
|
||||||
|
if (stranger) {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": ignoring call proposal from stranger "
|
||||||
|
+ id.with);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int activeDevices =
|
||||||
|
getDatabase()
|
||||||
|
.discoDao()
|
||||||
|
.countPresencesWithFeature(
|
||||||
|
getAccount(), Namespace.JINGLE_APPS_RTP);
|
||||||
|
Log.d(Config.LOGTAG, "active devices with rtp capability: " + activeDevices);
|
||||||
|
if (activeDevices == 0) {
|
||||||
|
final Message reject = MessageGenerator.sessionReject(from, sessionId);
|
||||||
|
connection.sendMessagePacket(reject);
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": ignoring proposal because busy on this device but"
|
||||||
|
+ " there are other devices");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final JingleRtpConnection rtpConnection =
|
||||||
|
new JingleRtpConnection(context, this.connection, id, from);
|
||||||
|
this.connections.put(id, rtpConnection);
|
||||||
|
rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
|
||||||
|
rtpConnection.deliveryMessage(from, message, serverMsgId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": unable to react to proposed session with "
|
||||||
|
+ rtpDescriptions.size()
|
||||||
|
+ " rtp descriptions of "
|
||||||
|
+ descriptions.size()
|
||||||
|
+ " total descriptions");
|
||||||
|
}
|
||||||
|
} else if (addressedDirectly && "proceed".equals(message.getName())) {
|
||||||
|
synchronized (rtpSessionProposals) {
|
||||||
|
final RtpSessionProposal proposal =
|
||||||
|
getRtpSessionProposal(from.asBareJid(), sessionId);
|
||||||
|
if (proposal != null) {
|
||||||
|
rtpSessionProposals.remove(proposal);
|
||||||
|
final JingleRtpConnection rtpConnection =
|
||||||
|
new JingleRtpConnection(
|
||||||
|
context,
|
||||||
|
this.connection,
|
||||||
|
id,
|
||||||
|
this.connection.getBoundAddress());
|
||||||
|
rtpConnection.setProposedMedia(proposal.media);
|
||||||
|
this.connections.put(id, rtpConnection);
|
||||||
|
rtpConnection.transitionOrThrow(AbstractJingleConnection.State.PROPOSED);
|
||||||
|
rtpConnection.deliveryMessage(from, message, serverMsgId);
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": no rtp session proposal found for "
|
||||||
|
+ from
|
||||||
|
+ " to deliver proceed");
|
||||||
|
if (remoteMsgId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Message errorMessage = new Message();
|
||||||
|
errorMessage.setTo(from);
|
||||||
|
errorMessage.setId(remoteMsgId);
|
||||||
|
errorMessage.setType(Message.Type.ERROR);
|
||||||
|
final Element error = errorMessage.addChild("error");
|
||||||
|
error.setAttribute("code", "404");
|
||||||
|
error.setAttribute("type", "cancel");
|
||||||
|
error.addChild("item-not-found", "urn:ietf:params:xml:ns:xmpp-stanzas");
|
||||||
|
connection.sendMessagePacket(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (addressedDirectly && "reject".equals(message.getName())) {
|
||||||
|
final RtpSessionProposal proposal = getRtpSessionProposal(from.asBareJid(), sessionId);
|
||||||
|
synchronized (rtpSessionProposals) {
|
||||||
|
if (proposal != null && rtpSessionProposals.remove(proposal) != null) {
|
||||||
|
writeLogMissedOutgoing(proposal.with, proposal.sessionId, serverMsgId);
|
||||||
|
ToneManager.getInstance(context)
|
||||||
|
.transition(RtpEndUserState.DECLINED_OR_BUSY, proposal.media);
|
||||||
|
notifyJingleRtpConnectionUpdate(
|
||||||
|
proposal.with, proposal.sessionId, RtpEndUserState.DECLINED_OR_BUSY);
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": no rtp session proposal found for "
|
||||||
|
+ from
|
||||||
|
+ " to deliver reject");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": retrieved out of order jingle message"
|
||||||
|
+ message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RtpSessionProposal getRtpSessionProposal(Jid from, String sessionId) {
|
||||||
|
for (RtpSessionProposal rtpSessionProposal : rtpSessionProposals.keySet()) {
|
||||||
|
if (rtpSessionProposal.sessionId.equals(sessionId)
|
||||||
|
&& rtpSessionProposal.with.equals(from)) {
|
||||||
|
return rtpSessionProposal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeLogMissedOutgoing(Jid with, final String sessionId, String serverMsgId) {}
|
||||||
|
|
||||||
|
private void writeLogMissedIncoming(Jid with, final String sessionId, String serverMsgId) {}
|
||||||
|
|
||||||
|
public Optional<OngoingRtpSession> getOngoingRtpConnection(final Jid contact) {
|
||||||
|
for (final Map.Entry<AbstractJingleConnection.Id, AbstractJingleConnection> entry :
|
||||||
|
this.connections.entrySet()) {
|
||||||
|
if (entry.getValue() instanceof JingleRtpConnection) {
|
||||||
|
final AbstractJingleConnection.Id id = entry.getKey();
|
||||||
|
if (id.with.asBareJid().equals(contact.asBareJid())) {
|
||||||
|
return Optional.of(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
|
||||||
|
this.rtpSessionProposals.entrySet()) {
|
||||||
|
final RtpSessionProposal proposal = entry.getKey();
|
||||||
|
if (contact.asBareJid().equals(proposal.with)) {
|
||||||
|
final DeviceDiscoveryState preexistingState = entry.getValue();
|
||||||
|
if (preexistingState != null
|
||||||
|
&& preexistingState != DeviceDiscoveryState.FAILED) {
|
||||||
|
return Optional.of(proposal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.absent();
|
||||||
|
}
|
||||||
|
|
||||||
|
void finishConnection(final AbstractJingleConnection connection) {
|
||||||
|
this.connections.remove(connection.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finishConnectionOrThrow(final AbstractJingleConnection connection) {
|
||||||
|
final AbstractJingleConnection.Id id = connection.getId();
|
||||||
|
if (this.connections.remove(id) == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
String.format("Unable to finish connection with id=%s", id.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean fireJingleRtpConnectionStateUpdates() {
|
||||||
|
boolean firedUpdates = false;
|
||||||
|
for (final AbstractJingleConnection connection : this.connections.values()) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
final JingleRtpConnection jingleRtpConnection = (JingleRtpConnection) connection;
|
||||||
|
if (jingleRtpConnection.isTerminated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jingleRtpConnection.fireStateUpdate();
|
||||||
|
firedUpdates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firedUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void retractSessionProposal(final Jid with) {
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
RtpSessionProposal matchingProposal = null;
|
||||||
|
for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) {
|
||||||
|
if (with.asBareJid().equals(proposal.with)) {
|
||||||
|
matchingProposal = proposal;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchingProposal != null) {
|
||||||
|
retractSessionProposal(matchingProposal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void retractSessionProposal(RtpSessionProposal rtpSessionProposal) {
|
||||||
|
ToneManager.getInstance(context)
|
||||||
|
.transition(RtpEndUserState.ENDED, rtpSessionProposal.media);
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": retracting rtp session proposal with "
|
||||||
|
+ rtpSessionProposal.with);
|
||||||
|
this.rtpSessionProposals.remove(rtpSessionProposal);
|
||||||
|
final Message messagePacket = MessageGenerator.sessionRetract(rtpSessionProposal);
|
||||||
|
writeLogMissedOutgoing(rtpSessionProposal.with, rtpSessionProposal.sessionId, null);
|
||||||
|
connection.sendMessagePacket(messagePacket);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String initializeRtpSession(final Jid with, final Set<Media> media) {
|
||||||
|
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(with);
|
||||||
|
final JingleRtpConnection rtpConnection =
|
||||||
|
new JingleRtpConnection(
|
||||||
|
context, this.connection, id, this.connection.getBoundAddress());
|
||||||
|
rtpConnection.setProposedMedia(media);
|
||||||
|
this.connections.put(id, rtpConnection);
|
||||||
|
rtpConnection.sendSessionInitiate();
|
||||||
|
return id.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void proposeJingleRtpSession(final Jid with, final Set<Media> media) {
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
|
||||||
|
this.rtpSessionProposals.entrySet()) {
|
||||||
|
RtpSessionProposal proposal = entry.getKey();
|
||||||
|
if (with.asBareJid().equals(proposal.with)) {
|
||||||
|
final DeviceDiscoveryState preexistingState = entry.getValue();
|
||||||
|
if (preexistingState != null
|
||||||
|
&& preexistingState != DeviceDiscoveryState.FAILED) {
|
||||||
|
final RtpEndUserState endUserState = preexistingState.toEndUserState();
|
||||||
|
ToneManager.getInstance(context).transition(endUserState, media);
|
||||||
|
this.notifyJingleRtpConnectionUpdate(
|
||||||
|
with, proposal.sessionId, endUserState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isBusy()) {
|
||||||
|
if (hasMatchingRtpSession(with, media)) {
|
||||||
|
LOGGER.debug(
|
||||||
|
"ignoring request to propose jingle session because the other party"
|
||||||
|
+ " already created one for us");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"There is already a running RTP session. This should have been caught by"
|
||||||
|
+ " the UI");
|
||||||
|
}
|
||||||
|
final RtpSessionProposal proposal = RtpSessionProposal.of(with.asBareJid(), media);
|
||||||
|
this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
|
||||||
|
this.notifyJingleRtpConnectionUpdate(
|
||||||
|
proposal.with, proposal.sessionId, RtpEndUserState.FINDING_DEVICE);
|
||||||
|
final Message messagePacket = MessageGenerator.sessionProposal(proposal);
|
||||||
|
connection.sendMessagePacket(messagePacket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasMatchingProposal(final Jid with) {
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
|
||||||
|
this.rtpSessionProposals.entrySet()) {
|
||||||
|
final RtpSessionProposal proposal = entry.getKey();
|
||||||
|
if (with.asBareJid().equals(proposal.with)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyRebound() {
|
||||||
|
for (final AbstractJingleConnection connection : this.connections.values()) {
|
||||||
|
connection.notifyRebound();
|
||||||
|
}
|
||||||
|
// TODO the old version did this only when SM was enabled?!
|
||||||
|
resendSessionProposals();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WeakReference<JingleRtpConnection> findJingleRtpConnection(Jid with, String sessionId) {
|
||||||
|
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(with, sessionId);
|
||||||
|
final AbstractJingleConnection connection = connections.get(id);
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
return new WeakReference<>((JingleRtpConnection) connection);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resendSessionProposals() {
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
|
||||||
|
this.rtpSessionProposals.entrySet()) {
|
||||||
|
final RtpSessionProposal proposal = entry.getKey();
|
||||||
|
if (entry.getValue() == DeviceDiscoveryState.SEARCHING) {
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": resending session proposal to "
|
||||||
|
+ proposal.with);
|
||||||
|
final Message messagePacket = MessageGenerator.sessionProposal(proposal);
|
||||||
|
connection.sendMessagePacket(messagePacket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateProposedSessionDiscovered(
|
||||||
|
Jid from, String sessionId, final DeviceDiscoveryState target) {
|
||||||
|
synchronized (this.rtpSessionProposals) {
|
||||||
|
final RtpSessionProposal sessionProposal =
|
||||||
|
getRtpSessionProposal(from.asBareJid(), sessionId);
|
||||||
|
final DeviceDiscoveryState currentState =
|
||||||
|
sessionProposal == null ? null : rtpSessionProposals.get(sessionProposal);
|
||||||
|
if (currentState == null) {
|
||||||
|
Log.d(Config.LOGTAG, "unable to find session proposal for session id " + sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentState == DeviceDiscoveryState.DISCOVERED) {
|
||||||
|
LOGGER.debug("session proposal already at discovered. not going to fall back");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.rtpSessionProposals.put(sessionProposal, target);
|
||||||
|
final RtpEndUserState endUserState = target.toEndUserState();
|
||||||
|
ToneManager.getInstance(context).transition(endUserState, sessionProposal.media);
|
||||||
|
this.notifyJingleRtpConnectionUpdate(
|
||||||
|
sessionProposal.with, sessionProposal.sessionId, endUserState);
|
||||||
|
LOGGER.debug(
|
||||||
|
connection.getAccount().address
|
||||||
|
+ ": flagging session "
|
||||||
|
+ sessionId
|
||||||
|
+ " as "
|
||||||
|
+ target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void rejectRtpSession(final String sessionId) {
|
||||||
|
for (final AbstractJingleConnection connection : this.connections.values()) {
|
||||||
|
if (connection.getId().sessionId.equals(sessionId)) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
try {
|
||||||
|
((JingleRtpConnection) connection).rejectCall();
|
||||||
|
return;
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
Log.w(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"race condition on rejecting call from notification",
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void endRtpSession(final String sessionId) {
|
||||||
|
for (final AbstractJingleConnection connection : this.connections.values()) {
|
||||||
|
if (connection.getId().sessionId.equals(sessionId)) {
|
||||||
|
if (connection instanceof JingleRtpConnection) {
|
||||||
|
((JingleRtpConnection) connection).endCall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void failProceed(final Jid with, final String sessionId, final String message) {
|
||||||
|
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(with, sessionId);
|
||||||
|
final AbstractJingleConnection existingJingleConnection = connections.get(id);
|
||||||
|
if (existingJingleConnection instanceof JingleRtpConnection) {
|
||||||
|
((JingleRtpConnection) existingJingleConnection).deliverFailedProceed(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ensureConnectionIsRegistered(final AbstractJingleConnection connection) {
|
||||||
|
if (connections.containsValue(connection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final IllegalStateException e =
|
||||||
|
new IllegalStateException(
|
||||||
|
"JingleConnection has not been registered with connection manager");
|
||||||
|
Log.e(Config.LOGTAG, "ensureConnectionIsRegistered() failed. Going to throw", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTerminalSessionState(
|
||||||
|
AbstractJingleConnection.Id id, final RtpEndUserState state, final Set<Media> media) {
|
||||||
|
this.terminatedSessions.put(
|
||||||
|
PersistableSessionId.of(id), new TerminatedRtpSession(state, media));
|
||||||
|
}
|
||||||
|
|
||||||
|
public TerminatedRtpSession getTerminalSessionState(final Jid with, final String sessionId) {
|
||||||
|
return this.terminatedSessions.getIfPresent(new PersistableSessionId(with, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyJingleRtpConnectionUpdate(
|
||||||
|
final Jid with, final String sessionId, final RtpEndUserState state) {
|
||||||
|
final var listener = this.onJingleRtpConnectionUpdate;
|
||||||
|
if (listener == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listener.onJingleRtpConnectionUpdate(with, sessionId, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyJingleRtpConnectionUpdate(
|
||||||
|
AppRTCAudioManager.AudioDevice selectedAudioDevice,
|
||||||
|
Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
|
||||||
|
final var listener = this.onJingleRtpConnectionUpdate;
|
||||||
|
if (listener == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listener.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnJingleRtpConnectionUpdate(final OnJingleRtpConnectionUpdate listener) {
|
||||||
|
this.onJingleRtpConnectionUpdate = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtpSessionNotification getNotificationService() {
|
||||||
|
return this.rtpSessionNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface OnJingleRtpConnectionUpdate {
|
||||||
|
void onJingleRtpConnectionUpdate(
|
||||||
|
final Jid with, final String sessionId, final RtpEndUserState state);
|
||||||
|
|
||||||
|
void onAudioDeviceChanged(
|
||||||
|
AppRTCAudioManager.AudioDevice selectedAudioDevice,
|
||||||
|
Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class PersistableSessionId {
|
||||||
|
private final Jid with;
|
||||||
|
private final String sessionId;
|
||||||
|
|
||||||
|
private PersistableSessionId(Jid with, String sessionId) {
|
||||||
|
this.with = with;
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PersistableSessionId of(AbstractJingleConnection.Id id) {
|
||||||
|
return new PersistableSessionId(id.with, id.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
PersistableSessionId that = (PersistableSessionId) o;
|
||||||
|
return Objects.equal(with, that.with) && Objects.equal(sessionId, that.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(with, sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TerminatedRtpSession {
|
||||||
|
public final RtpEndUserState state;
|
||||||
|
public final Set<Media> media;
|
||||||
|
|
||||||
|
TerminatedRtpSession(RtpEndUserState state, Set<Media> media) {
|
||||||
|
this.state = state;
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeviceDiscoveryState {
|
||||||
|
SEARCHING,
|
||||||
|
SEARCHING_ACKNOWLEDGED,
|
||||||
|
DISCOVERED,
|
||||||
|
FAILED;
|
||||||
|
|
||||||
|
public RtpEndUserState toEndUserState() {
|
||||||
|
switch (this) {
|
||||||
|
case SEARCHING:
|
||||||
|
case SEARCHING_ACKNOWLEDGED:
|
||||||
|
return RtpEndUserState.FINDING_DEVICE;
|
||||||
|
case DISCOVERED:
|
||||||
|
return RtpEndUserState.RINGING;
|
||||||
|
default:
|
||||||
|
return RtpEndUserState.CONNECTIVITY_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RtpSessionProposal implements OngoingRtpSession {
|
||||||
|
public final Jid with;
|
||||||
|
public final String sessionId;
|
||||||
|
public final Set<Media> media;
|
||||||
|
|
||||||
|
private RtpSessionProposal(Jid with, String sessionId) {
|
||||||
|
this(with, sessionId, Collections.emptySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RtpSessionProposal(Jid with, String sessionId, Set<Media> media) {
|
||||||
|
this.with = with;
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtpSessionProposal of(Jid with, Set<Media> media) {
|
||||||
|
return new RtpSessionProposal(with, IDs.medium(), media);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Jid getWith() {
|
||||||
|
return with;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getSessionId() {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
RtpSessionProposal that = (RtpSessionProposal) o;
|
||||||
|
return Objects.equal(with, that.with)
|
||||||
|
&& Objects.equal(sessionId, that.sessionId)
|
||||||
|
&& Objects.equal(media, that.media);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hashCode(with, sessionId, media);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import im.conversations.android.xml.Namespace;
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import im.conversations.android.xmpp.Entity;
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
import im.conversations.android.xmpp.model.stanza.Message;
|
import im.conversations.android.xmpp.model.stanza.Message;
|
||||||
import im.conversations.android.xmpp.model.unique.StanzaId;
|
import im.conversations.android.xmpp.model.unique.StanzaId;
|
||||||
|
@ -25,7 +26,8 @@ public class StanzaIdManager extends AbstractManager {
|
||||||
by = connection.getBoundAddress().asBareJid();
|
by = connection.getBoundAddress().asBareJid();
|
||||||
}
|
}
|
||||||
if (message.hasExtension(StanzaId.class)
|
if (message.hasExtension(StanzaId.class)
|
||||||
&& getManager(DiscoManager.class).hasFeature(by, Namespace.STANZA_IDS)) {
|
&& getManager(DiscoManager.class)
|
||||||
|
.hasFeature(Entity.discoItem(by), Namespace.STANZA_IDS)) {
|
||||||
return getStanzaIdBy(message, by);
|
return getStanzaIdBy(message, by);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -13,4 +13,12 @@ public class Encrypted extends Extension {
|
||||||
public boolean hasPayload() {
|
public boolean hasPayload() {
|
||||||
return hasExtension(Payload.class);
|
return hasExtension(Payload.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Header getHeader() {
|
||||||
|
return getExtension(Header.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Payload getPayload() {
|
||||||
|
return getExtension(Payload.class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
package im.conversations.android.xmpp.model.axolotl;
|
package im.conversations.android.xmpp.model.axolotl;
|
||||||
|
|
||||||
|
import com.google.common.base.Optional;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
import im.conversations.android.annotation.XmlElement;
|
import im.conversations.android.annotation.XmlElement;
|
||||||
import im.conversations.android.xmpp.model.Extension;
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@XmlElement
|
@XmlElement
|
||||||
public class Header extends Extension {
|
public class Header extends Extension {
|
||||||
|
@ -9,4 +13,33 @@ public class Header extends Extension {
|
||||||
public Header() {
|
public Header() {
|
||||||
super(Header.class);
|
super(Header.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void addIv(byte[] iv) {
|
||||||
|
this.addExtension(new IV()).setContent(iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceDevice(long sourceDeviceId) {
|
||||||
|
this.setAttribute("sid", sourceDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Integer> getSourceDevice() {
|
||||||
|
return getOptionalIntAttribute("sid");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<Key> getKeys() {
|
||||||
|
return this.getExtensions(Key.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Key getKey(final int deviceId) {
|
||||||
|
return Iterables.find(
|
||||||
|
getKeys(), key -> Objects.equals(key.getRemoteDeviceId(), deviceId), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getIv() {
|
||||||
|
final IV iv = this.getExtension(IV.class);
|
||||||
|
if (iv == null) {
|
||||||
|
throw new IllegalStateException("No IV in header");
|
||||||
|
}
|
||||||
|
return iv.asBytes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package im.conversations.android.xmpp.model.axolotl;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlElement;
|
||||||
|
import im.conversations.android.xmpp.model.ByteContent;
|
||||||
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
|
||||||
|
@XmlElement
|
||||||
|
public class IV extends Extension implements ByteContent {
|
||||||
|
|
||||||
|
public IV() {
|
||||||
|
super(IV.class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
package im.conversations.android.xmpp.model.axolotl;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlElement;
|
||||||
|
import im.conversations.android.xmpp.model.ByteContent;
|
||||||
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
|
||||||
|
@XmlElement
|
||||||
|
public class Key extends Extension implements ByteContent {
|
||||||
|
|
||||||
|
public Key() {
|
||||||
|
super(Key.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsPreKey(boolean isPreKey) {
|
||||||
|
this.setAttribute("prekey", isPreKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPreKey() {
|
||||||
|
return this.getAttributeAsBoolean("prekey");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRemoteDeviceId(final int remoteDeviceId) {
|
||||||
|
this.setAttribute("rid", remoteDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRemoteDeviceId() {
|
||||||
|
return getOptionalIntAttribute("rid").orNull();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
package im.conversations.android.xmpp.model.axolotl;
|
package im.conversations.android.xmpp.model.axolotl;
|
||||||
|
|
||||||
import im.conversations.android.annotation.XmlElement;
|
import im.conversations.android.annotation.XmlElement;
|
||||||
|
import im.conversations.android.xmpp.model.ByteContent;
|
||||||
import im.conversations.android.xmpp.model.Extension;
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
|
||||||
@XmlElement
|
@XmlElement
|
||||||
public class Payload extends Extension {
|
public class Payload extends Extension implements ByteContent {
|
||||||
|
|
||||||
public Payload() {
|
public Payload() {
|
||||||
super(Payload.class);
|
super(Payload.class);
|
||||||
|
|
12
app/src/main/java/im/conversations/android/xmpp/model/disco/external/Service.java
vendored
Normal file
12
app/src/main/java/im/conversations/android/xmpp/model/disco/external/Service.java
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package im.conversations.android.xmpp.model.disco.external;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlElement;
|
||||||
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
|
||||||
|
@XmlElement
|
||||||
|
public class Service extends Extension {
|
||||||
|
|
||||||
|
public Service() {
|
||||||
|
super(Service.class);
|
||||||
|
}
|
||||||
|
}
|
12
app/src/main/java/im/conversations/android/xmpp/model/disco/external/Services.java
vendored
Normal file
12
app/src/main/java/im/conversations/android/xmpp/model/disco/external/Services.java
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package im.conversations.android.xmpp.model.disco.external;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlElement;
|
||||||
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
|
||||||
|
@XmlElement
|
||||||
|
public class Services extends Extension {
|
||||||
|
|
||||||
|
public Services() {
|
||||||
|
super(Services.class);
|
||||||
|
}
|
||||||
|
}
|
5
app/src/main/java/im/conversations/android/xmpp/model/disco/external/package-info.java
vendored
Normal file
5
app/src/main/java/im/conversations/android/xmpp/model/disco/external/package-info.java
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
@XmlPackage(namespace = Namespace.EXTERNAL_SERVICE_DISCOVERY)
|
||||||
|
package im.conversations.android.xmpp.model.disco.external;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlPackage;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
|
@ -6,7 +6,6 @@ import im.conversations.android.xmpp.model.Extension;
|
||||||
@XmlElement
|
@XmlElement
|
||||||
public class Jingle extends Extension {
|
public class Jingle extends Extension {
|
||||||
|
|
||||||
|
|
||||||
public Jingle() {
|
public Jingle() {
|
||||||
super(Jingle.class);
|
super(Jingle.class);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
@XmlPackage(namespace = Namespace.JINGLE)
|
||||||
|
package im.conversations.android.xmpp.model.jingle;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlPackage;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
|
@ -0,0 +1,8 @@
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
public class Accept extends JingleMessage {
|
||||||
|
|
||||||
|
public Accept() {
|
||||||
|
super(Accept.class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
import im.conversations.android.xmpp.model.Extension;
|
||||||
|
|
||||||
|
public abstract class JingleMessage extends Extension {
|
||||||
|
|
||||||
|
public JingleMessage(Class<? extends Extension> clazz) {
|
||||||
|
super(clazz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionId() {
|
||||||
|
return this.getAttribute("id");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Ints;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
|
||||||
|
public class Proceed extends JingleMessage {
|
||||||
|
|
||||||
|
public Proceed() {
|
||||||
|
super(Propose.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDeviceId() {
|
||||||
|
final Element device = this.findChild("device");
|
||||||
|
final String id = device == null ? null : device.getAttribute("id");
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Ints.tryParse(id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Propose extends JingleMessage {
|
||||||
|
|
||||||
|
public Propose() {
|
||||||
|
super(Propose.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GenericDescription> getDescriptions() {
|
||||||
|
final ImmutableList.Builder<GenericDescription> builder = new ImmutableList.Builder<>();
|
||||||
|
for (final Element child : this.children) {
|
||||||
|
if ("description".equals(child.getName())) {
|
||||||
|
final String namespace = child.getNamespace();
|
||||||
|
if (FileTransferDescription.NAMESPACES.contains(namespace)) {
|
||||||
|
builder.add(FileTransferDescription.upgrade(child));
|
||||||
|
} else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
|
||||||
|
builder.add(RtpDescription.upgrade(child));
|
||||||
|
} else {
|
||||||
|
builder.add(GenericDescription.upgrade(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
public class Reject extends JingleMessage {
|
||||||
|
|
||||||
|
public Reject() {
|
||||||
|
super(Reject.class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
public class Retract extends JingleMessage {
|
||||||
|
|
||||||
|
public Retract() {
|
||||||
|
super(Retract.class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
@XmlPackage(namespace = Namespace.JINGLE_MESSAGE)
|
||||||
|
package im.conversations.android.xmpp.model.jmi;
|
||||||
|
|
||||||
|
import im.conversations.android.annotation.XmlPackage;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
|
@ -73,6 +73,7 @@ public class IqProcessor extends XmppConnection.Delegate implements Consumer<Iq>
|
||||||
|
|
||||||
if (type == Iq.Type.SET && packet.hasExtension(Jingle.class)) {
|
if (type == Iq.Type.SET && packet.hasExtension(Jingle.class)) {
|
||||||
getManager(JingleConnectionManager.class).handleJingle(packet);
|
getManager(JingleConnectionManager.class).handleJingle(packet);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final var extensionIds = packet.getExtensionIds();
|
final var extensionIds = packet.getExtensionIds();
|
||||||
|
|
5
app/src/main/res/drawable/ic_bluetooth_audio_24dp.xml
Normal file
5
app/src/main/res/drawable/ic_bluetooth_audio_24dp.xml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M14.24,12.01l2.32,2.32c0.28,-0.72 0.44,-1.51 0.44,-2.33 0,-0.82 -0.16,-1.59 -0.43,-2.31l-2.33,2.32zM19.53,6.71l-1.26,1.26c0.63,1.21 0.98,2.57 0.98,4.02s-0.36,2.82 -0.98,4.02l1.2,1.2c0.97,-1.54 1.54,-3.36 1.54,-5.31 -0.01,-1.89 -0.55,-3.67 -1.48,-5.19zM15.71,7.71L10,2L9,2v7.59L4.41,5 3,6.41 8.59,12 3,17.59 4.41,19 9,14.41L9,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM11,5.83l1.88,1.88L11,9.59L11,5.83zM12.88,16.29L11,18.17v-3.76l1.88,1.88z"/>
|
||||||
|
</vector>
|
5
app/src/main/res/drawable/ic_call_24dp.xml
Normal file
5
app/src/main/res/drawable/ic_call_24dp.xml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M20.01,15.38c-1.23,0 -2.42,-0.2 -3.53,-0.56 -0.35,-0.12 -0.74,-0.03 -1.01,0.24l-1.57,1.97c-2.83,-1.35 -5.48,-3.9 -6.89,-6.83l1.95,-1.66c0.27,-0.28 0.35,-0.67 0.24,-1.02 -0.37,-1.11 -0.56,-2.3 -0.56,-3.53 0,-0.54 -0.45,-0.99 -0.99,-0.99H4.19C3.65,3 3,3.24 3,3.99 3,13.28 10.73,21 20.01,21c0.71,0 0.99,-0.63 0.99,-1.18v-3.45c0,-0.54 -0.45,-0.99 -0.99,-0.99z"/>
|
||||||
|
</vector>
|
5
app/src/main/res/drawable/ic_call_end_24dp.xml
Normal file
5
app/src/main/res/drawable/ic_call_end_24dp.xml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z"/>
|
||||||
|
</vector>
|
|
@ -1,11 +1,5 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector android:autoMirrored="true" android:height="24dp"
|
||||||
android:width="24dp"
|
android:tint="#000000" android:viewportHeight="24"
|
||||||
android:height="24dp"
|
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
android:autoMirrored="true"
|
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z"/>
|
||||||
android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|
5
app/src/main/res/drawable/ic_check_24dp.xml
Normal file
5
app/src/main/res/drawable/ic_check_24dp.xml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<vector android:height="24dp" android:tint="#000000"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
|
||||||
|
</vector>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue