port jingle rtp connection

This commit is contained in:
Daniel Gultsch 2023-02-22 22:22:25 +01:00
parent d7ab5e1a4b
commit eafa93d132
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
122 changed files with 13427 additions and 74 deletions

View file

@ -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'

View file

@ -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>

View 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
}

View file

@ -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;
}
}

View file

@ -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();
}
}
}

View file

@ -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);
}
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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 havent 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
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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");
}
}
}

View file

@ -0,0 +1,5 @@
package eu.siacs.conversations.xmpp.jingle;
public interface OnPrimaryCandidateFound {
void onPrimaryCandidateFound(boolean success, JingleCandidate canditate);
}

View file

@ -0,0 +1,7 @@
package eu.siacs.conversations.xmpp.jingle;
public interface OnTransportConnected {
void failed();
void established();
}

View file

@ -0,0 +1,9 @@
package eu.siacs.conversations.xmpp.jingle;
import org.jxmpp.jid.Jid;
public interface OngoingRtpSession {
Jid getWith();
String getSessionId();
}

View file

@ -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);
}
}
}

View file

@ -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
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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());
}
}

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}
}

View file

@ -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));
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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");
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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");
}
}

View file

@ -0,0 +1,8 @@
package im.conversations.android.axolotl;
public class OutdatedSenderException extends AxolotlDecryptionException {
public OutdatedSenderException(final String message) {
super(message);
}
}

View file

@ -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() {

View file

@ -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()));
}
} }

View file

@ -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) {

View file

@ -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));
}
}

View file

@ -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);
}
} }

View file

@ -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;
} }

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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) {}
}

View file

@ -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

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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);
} }

View file

@ -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,

View file

@ -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 dont have public method except for accept|apply // these consumers are pure listeners; they dont have public method except for accept|apply

View file

@ -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();
}
} }

View file

@ -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() {

View file

@ -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());
}
}

View file

@ -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 dont 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);
}
} }
} }

View file

@ -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;

View file

@ -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);
}
} }

View file

@ -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();
}
} }

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);

View 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);
}
}

View 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);
}
}

View 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;

View file

@ -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);
} }

View file

@ -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;

View file

@ -0,0 +1,8 @@
package im.conversations.android.xmpp.model.jmi;
public class Accept extends JingleMessage {
public Accept() {
super(Accept.class);
}
}

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,8 @@
package im.conversations.android.xmpp.model.jmi;
public class Reject extends JingleMessage {
public Reject() {
super(Reject.class);
}
}

View file

@ -0,0 +1,8 @@
package im.conversations.android.xmpp.model.jmi;
public class Retract extends JingleMessage {
public Retract() {
super(Retract.class);
}
}

View file

@ -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;

View file

@ -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();

View 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>

View 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>

View 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>

View file

@ -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>

View 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