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
implementation 'org.jxmpp:jxmpp-jid:1.0.3'
// WebRTC
implementation 'im.conversations.webrtc:webrtc-android:104.0.0'
// Consistent Color Generation
implementation 'org.hsluv:hsluv:0.2'
@ -116,15 +120,19 @@ dependencies {
// DNS library (XMPP needs to resolve SRV records)
implementation 'de.measite.minidns:minidns-hla:0.2.4'
// Guava
implementation 'com.google.guava:guava:31.1-android'
// HTTP library
implementation "com.squareup.okhttp3:okhttp:4.10.0"
// JSON parser
implementation 'com.google.code.gson:gson:2.10.1'
// logging framework + logging api
implementation 'org.slf4j:slf4j-api:1.7.36'
implementation 'com.github.tony19:logback-android:2.0.1'

View file

@ -106,6 +106,11 @@
<activity
android:name="im.conversations.android.ui.activity.SetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.activity.RtpSessionActivity"
android:autoRemoveFromRecents="true"
android:launchMode="singleInstance"
android:supportsPictureInPicture="true" />
</application>
</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.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;
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.model.Account;
import im.conversations.android.xmpp.axolotl.AxolotlAddress;
import java.util.List;
import org.whispersystems.libsignal.IdentityKey;
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.SignedPreKeyRecord;
public class AxolotlDatabaseStore implements SignalProtocolStore {
private final Context context;
private final Account account;
public class AxolotlDatabaseStore extends AbstractAccountService implements SignalProtocolStore {
public AxolotlDatabaseStore(final Context context, final Account account) {
this.context = context;
this.account = account;
super(context, account);
}
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.items.Item;
import java.util.Collection;
import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.Jid;
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"
+ " disco_item.discoId=disco_feature.discoId WHERE accountId=:account AND"
+ " 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.xmpp.model.roster.Item;
import java.util.Collection;
import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.Jid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -33,6 +34,9 @@ public abstract class RosterDao extends GroupDao {
@Query("UPDATE account SET rosterVersion=:version WHERE id=:account")
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
public void set(
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 {
private final Application application;
private static final String CHANNEL_GROUP_STATUS = "status";
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) {
this.application = application;
@ -29,6 +30,8 @@ public final class Channels {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
this.initializeGroups(notificationManager);
this.initializeForegroundChannel(notificationManager);
this.initializeIncomingCallChannel(notificationManager);
}
}
@ -38,6 +41,10 @@ public final class Channels {
new NotificationChannelGroup(
CHANNEL_GROUP_STATUS,
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)
@ -55,4 +62,21 @@ public final class Channels {
foregroundServiceChannel.setGroup(CHANNEL_GROUP_STATUS);
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.xmpp.ConnectionPool;
public class ForegroundServiceNotification {
public class ForegroundServiceNotification extends AbstractNotification {
public static final int ID = 1;
private final Service service;
public ForegroundServiceNotification(final Service service) {
this.service = service;
super(service);
}
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) {
// 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'
builder.setContentTitle(
service.getString(
context.getString(
R.string.connected_accounts, summary.connected, summary.total));
} else {
builder.setContentTitle(service.getString(R.string.app_name));
builder.setContentTitle(context.getString(R.string.app_name));
builder.setContentText(
service.getString(
context.getString(
R.string.connected_accounts, summary.connected, summary.total));
}
builder.setContentIntent(buildPendingIntent());
@ -53,15 +51,15 @@ public class ForegroundServiceNotification {
private PendingIntent buildPendingIntent() {
return PendingIntent.getActivity(
service,
context,
0,
new Intent(service, MainActivity.class),
new Intent(context, MainActivity.class),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
}
public void update(final ConnectionPool.Summary summary) {
final var notificationManager =
ContextCompat.getSystemService(service, NotificationManager.class);
ContextCompat.getSystemService(context, NotificationManager.class);
if (notificationManager == null) {
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;
import android.content.Context;
import im.conversations.android.AbstractAccountService;
import im.conversations.android.database.model.Account;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import javax.net.ssl.X509TrustManager;
public class TrustManager implements X509TrustManager {
private final Context context;
private final Account account;
public class TrustManager extends AbstractAccountService implements X509TrustManager {
public TrustManager(final Context context, final Account account) {
this.context = context;
this.account = account;
super(context, account);
}
@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 im.conversations.android.xml.Namespace;
import im.conversations.android.xmpp.Entity;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.DiscoManager;
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 (from != null
&& getManager(DiscoManager.class)
.hasFeature(from.asBareJid(), Namespace.OCCUPANT_ID)) {
.hasFeature(
Entity.discoItem(from.asBareJid()), Namespace.OCCUPANT_ID)) {
occupantId = message.getExtension(OccupantId.class).getId();
} else {
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());
}
public void setAttribute(final String name, final boolean value) {
this.setAttribute(name, value ? "1" : "0");
}
public void removeAttribute(final String 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.ChatStateManager;
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.JingleConnectionManager;
import im.conversations.android.xmpp.manager.NickManager;
@ -38,6 +39,7 @@ public final class Managers {
.put(CarbonsManager.class, new CarbonsManager(context, connection))
.put(ChatStateManager.class, new ChatStateManager(context, connection))
.put(DiscoManager.class, new DiscoManager(context, connection))
.put(ExternalDiscoManager.class, new ExternalDiscoManager(context, connection))
.put(HttpUploadManager.class, new HttpUploadManager(context, connection))
.put(
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.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import im.conversations.android.AbstractAccountService;
import im.conversations.android.BuildConfig;
import im.conversations.android.Conversations;
import im.conversations.android.IDs;
@ -105,17 +106,15 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 boolean EXTENDED_SM_LOGGING = false;
private static final int CONNECT_DISCO_TIMEOUT = 20;
protected final Account account;
private final SparseArray<Stanza> mStanzaQueue = new SparseArray<>();
private final Hashtable<String, Pair<Iq, Consumer<Iq>>> packetCallbacks = new Hashtable<>();
private final Context context;
private Socket socket;
private XmlReader tagReader;
private TagWriter tagWriter = new TagWriter();
@ -156,8 +155,7 @@ public class XmppConnection implements Runnable {
private CountDownLatch mStreamCountDownLatch;
public XmppConnection(final Context context, final Account account) {
this.context = context;
this.account = account;
super(context, account);
this.connectionAddress = account.address;
// 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 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.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
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.xmpp.IqErrorException;
import im.conversations.android.xmpp.NodeConfiguration;
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.DeviceList;
import im.conversations.android.xmpp.model.axolotl.Encrypted;
import im.conversations.android.xmpp.model.pubsub.Items;
import java.util.Collection;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeoutException;
import org.jxmpp.jid.BareJid;
@ -28,7 +44,6 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.SessionBuilder;
import org.whispersystems.libsignal.SessionCipher;
import org.whispersystems.libsignal.UntrustedIdentityException;
import org.whispersystems.libsignal.state.PreKeyBundle;
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 final SignalProtocolStore signalProtocolStore;
private final AxolotlService axolotlService;
public AxolotlManager(Context context, XmppConnection 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) {
@ -103,25 +118,27 @@ public class AxolotlManager extends AbstractManager {
return getManager(PubSubManager.class).fetchMostRecentItem(address, node, Bundle.class);
}
public ListenableFuture<SessionCipher> getOrCreateSessionCipher(
public ListenableFuture<AxolotlSession> getOrCreateSessionCipher(
final AxolotlAddress axolotlAddress) {
if (signalProtocolStore.containsSession(axolotlAddress)) {
return Futures.immediateFuture(new SessionCipher(signalProtocolStore, axolotlAddress));
final AxolotlSession session = axolotlService.getExistingSession(axolotlAddress);
if (session != null) {
return Futures.immediateFuture(session);
} else {
final var bundleFuture =
fetchBundle(axolotlAddress.getJid(), axolotlAddress.getDeviceId());
return Futures.transform(
bundleFuture,
bundle -> {
buildSession(axolotlAddress, bundle);
return new SessionCipher(signalProtocolStore, axolotlAddress);
final var identityKey = buildSession(axolotlAddress, bundle);
return AxolotlSession.of(
signalProtocolStore(), identityKey, axolotlAddress);
},
MoreExecutors.directExecutor());
}
}
private void buildSession(final AxolotlAddress address, final Bundle bundle) {
final var sessionBuilder = new SessionBuilder(signalProtocolStore, address);
private IdentityKey buildSession(final AxolotlAddress address, final Bundle bundle) {
final var sessionBuilder = new SessionBuilder(signalProtocolStore(), address);
final var deviceId = address.getDeviceId();
final var preKey = bundle.getRandomPreKey();
final var signedPreKey = bundle.getSignedPreKey();
@ -139,6 +156,7 @@ public class AxolotlManager extends AbstractManager {
if (identityKey == null) {
throw new IllegalArgumentException("No IdentityKey found in bundle");
}
final var signalIdentityKey = new IdentityKey(identityKey.asECPublicKey());
final var preKeyBundle =
new PreKeyBundle(
0,
@ -148,9 +166,10 @@ public class AxolotlManager extends AbstractManager {
signedPreKey.getId(),
signedPreKey.asECPublicKey(),
signedPreKeySignature.asBytes(),
new IdentityKey(identityKey.asECPublicKey()));
signalIdentityKey);
try {
sessionBuilder.process(preKeyBundle);
return signalIdentityKey;
} catch (final InvalidKeyException | UntrustedIdentityException e) {
throw new RuntimeException(e);
}
@ -249,7 +268,7 @@ public class AxolotlManager extends AbstractManager {
Locale.ROOT,
"%s:%d",
Namespace.AXOLOTL_BUNDLES,
signalProtocolStore.getLocalRegistrationId());
signalProtocolStore().getLocalRegistrationId());
return getManager(PepManager.class)
.publishSingleton(bundle, node, NodeConfiguration.OPEN);
},
@ -260,7 +279,7 @@ public class AxolotlManager extends AbstractManager {
refillPreKeys();
final var bundle = new Bundle();
bundle.setIdentityKey(
signalProtocolStore.getIdentityKeyPair().getPublicKey().getPublicKey());
signalProtocolStore().getIdentityKeyPair().getPublicKey().getPublicKey());
final var signedPreKeyRecord =
getDatabase().axolotlDao().getLatestSignedPreKey(getAccount().id);
if (signedPreKeyRecord == null) {
@ -286,11 +305,11 @@ public class AxolotlManager extends AbstractManager {
try {
signedPreKeyRecord =
KeyHelper.generateSignedPreKey(
signalProtocolStore.getIdentityKeyPair(), signedPreKeyId);
signalProtocolStore().getIdentityKeyPair(), signedPreKeyId);
} catch (final InvalidKeyException 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());
}
axolotlDao.setPreKeys(getAccount(), preKeys);
@ -298,4 +317,165 @@ public class AxolotlManager extends AbstractManager {
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.List;
import java.util.Objects;
import org.jxmpp.jid.Jid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -67,8 +66,8 @@ public class DiscoManager extends AbstractManager {
Namespace.JINGLE_FEATURE_AUDIO,
Namespace.JINGLE_FEATURE_VIDEO,
Namespace.JINGLE_APPS_RTP,
Namespace.JINGLE_APPS_DTLS,
Namespace.JINGLE_MESSAGE);
Namespace.JINGLE_APPS_DTLS /*,
Namespace.JINGLE_MESSAGE*/);
private static final Collection<String> FEATURES_IMPACTING_PRIVACY =
Collections.singleton(Namespace.VERSION);
@ -241,16 +240,28 @@ public class DiscoManager extends AbstractManager {
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);
}
public ListenableFuture<Boolean> hasFeatureAsync(final Entity entity, final String feature) {
return Futures.submit(() -> hasFeature(entity, feature), getDatabase().getQueryExecutor());
}
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) {
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() {

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;
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.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.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 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);
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 im.conversations.android.xml.Namespace;
import im.conversations.android.xmpp.Entity;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.stanza.Message;
import im.conversations.android.xmpp.model.unique.StanzaId;
@ -25,7 +26,8 @@ public class StanzaIdManager extends AbstractManager {
by = connection.getBoundAddress().asBareJid();
}
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);
} else {
return null;

View file

@ -13,4 +13,12 @@ public class Encrypted extends Extension {
public boolean hasPayload() {
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;
import com.google.common.base.Optional;
import com.google.common.collect.Iterables;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.Extension;
import java.util.Collection;
import java.util.Objects;
@XmlElement
public class Header extends Extension {
@ -9,4 +13,33 @@ public class Header extends Extension {
public Header() {
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;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.xmpp.model.ByteContent;
import im.conversations.android.xmpp.model.Extension;
@XmlElement
public class Payload extends Extension {
public class Payload extends Extension implements ByteContent {
public Payload() {
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
public class Jingle extends Extension {
public Jingle() {
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)) {
getManager(JingleConnectionManager.class).handleJingle(packet);
return;
}
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"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
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 android:autoMirrored="true" 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,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>

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