Merge tag '2.8.0' into develop

This commit is contained in:
genofire 2020-04-25 17:59:48 +02:00
commit 749ff88ed0
247 changed files with 11131 additions and 3088 deletions

View File

@ -9,6 +9,8 @@ android:
- extra-google-google_play_services
licenses:
- '.+'
before_script:
- wget -O libs/libwebrtc-m81.aar http://gultsch.de/files/libwebrtc-m81.aar
script:
- ./gradlew assembleConversationsFreeSystemRelease
- ./gradlew assembleQuicksyFreeCompatRelease

View File

@ -1,5 +1,10 @@
# Changelog
### Version 2.8.0
* Audio/Video calls (Requires server support in form of STUN and TURN servers discoverable via XEP-0215)
### Version 2.7.1
* Fix avatar selection on some Android 10 devices

View File

@ -43,6 +43,7 @@
* End-to-end encryption with [OMEMO](http://conversations.im/omemo/) or [OpenPGP](http://openpgp.org/about/)
* Send and receive images as well as other kind of files
* Make audio and video calls
* Share your location
* Send voice messages
* Indication when your contact has read your message
@ -150,7 +151,7 @@ However you can disable the notification via settings of the operating system. (
**The battery consumption and the entire behaviour of Conversations will remain the same (as good or as bad as it was before). Why is Google doing this to you? We have no idea.**
##### Android <= 7.1
##### Android <= 7.1 or Conversations from F-Droid (all Android versions)
The foreground notification is still controlled over the expert settings within Conversations as it always has been. Whether or not you need to enable it depends on how aggressive the non-standard 'power saving' features are that your phone vendor has built into the operating system.
##### Android 8.x
@ -367,6 +368,12 @@ Unfortunately we dont have a recommendation for iPhones right now. There are
#### How do I build Conversations
**Note:** Starting with version 2.8.0 you will need to compile libwebrtc.
[Instructions](https://webrtc.github.io/webrtc-org/native-code/android/) can be found on the WebRTC
website. Place the resulting libwebrtc.aar in the `libs/` directory. The PlayStore release currently
uses the stable M81 release and renamed the file name to `libwebrtc-m81.aar` put potentially you can
reference any file name by modifying `build.gradle`.
Make sure to have ANDROID_HOME point to your Android SDK. Use the Android SDK Manager to install missing dependencies.
git clone https://github.com/siacs/Conversations.git
@ -412,7 +419,7 @@ Debian/Ubuntu for example it is called `android-tools-adb`.
Furthermore you might have to enable 'USB debugging' in the Developer options of your
phone. After that you can just execute the following on your computer:
adb -d logcat -v time -s conversations
adb -d logcat -v time -s conver6ations
If need be there are also some Apps on the PlayStore that can be used to show the logcat
directly on your rooted phone. (Search for logcat). However in regards to further processing

View File

@ -1,3 +1,5 @@
import com.android.build.OutputFile
// Top-level build file where you can add configuration options common to all
// sub-projects/modules.
buildscript {
@ -6,7 +8,7 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.3'
classpath 'com.android.tools.build:gradle:3.6.2'
}
}
@ -33,13 +35,14 @@ ext {
}
dependencies {
//should remain that low because later versions introduce dependency to androidx (not sure exactly from what version)
playstoreImplementation('com.google.firebase:firebase-messaging:17.3.4') {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
}
conversationsPlaystoreCompatImplementation("com.android.installreferrer:installreferrer:1.1")
conversationsPlaystoreSystemImplementation("com.android.installreferrer:installreferrer:1.1")
conversationsPlaystoreCompatImplementation("com.android.installreferrer:installreferrer:1.1.2")
conversationsPlaystoreSystemImplementation("com.android.installreferrer:installreferrer:1.1.2")
implementation 'org.sufficientlysecure:openpgp-api:10.0'
implementation('com.theartofdev.edmodo:android-image-cropper:2.7.+') {
exclude group: 'com.android.support', module: 'appcompat-v7'
@ -74,14 +77,17 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:2.6.4"
implementation "com.squareup.retrofit2:converter-gson:2.6.4"
//okhttp needs to stick with 3.12.x
implementation 'com.squareup.okhttp3:okhttp:3.12.7'
implementation 'com.squareup.okhttp3:okhttp:3.12.10'
implementation 'com.google.guava:guava:27.1-android'
quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.11.1'
//implementation fileTree(include: ['libwebrtc-m81.aar'], dir: 'libs')
implementation 'org.webrtc:google-webrtc:1.0.+'
}
ext {
travisBuild = System.getenv("TRAVIS") == "true"
preDexEnabled = System.getProperty("pre-dex", "true")
abiCodes = ['armeabi-v7a': 1, 'x86': 2, 'x86_64': 3, 'arm64-v8a': 4]
}
android {
@ -90,8 +96,8 @@ android {
defaultConfig {
minSdkVersion 16
targetSdkVersion 25
versionCode 367
versionName "2.7.1"
versionCode 379
versionName "2.8.0"
archivesBaseName += "-$versionName"
applicationId "eu.sum7.conversations"
resValue "string", "applicationId", applicationId
@ -99,6 +105,7 @@ android {
buildConfigField "String", "LOGTAG", "\"conver6ations\""
}
dataBinding {
enabled true
}
@ -247,4 +254,5 @@ android {
exclude 'META-INF/BCKEY.DSA'
exclude 'META-INF/BCKEY.SF'
}
}

View File

@ -0,0 +1,39 @@
Easy to use, reliable, battery friendly. With built-in support for images, group chats and e2e encryption.
Design principles:
* Be as beautiful and easy to use as possible without sacrificing security or privacy
* Rely on existing, well established protocols
* Do not require a Google Account or specifically Google Cloud Messaging (GCM)
* Require as few permissions as possible
Features:
* End-to-end encryption with either <a href="http://conversations.im/omemo/">OMEMO</a> or <a href="http://openpgp.org/about/">OpenPGP</a>
* Sending and receiving images
* Make audio and video calls
* Intuitive UI that follows Android Design guidelines
* Pictures / Avatars for your Contacts
* Syncs with desktop client
* Conferences (with support for bookmarks)
* Address book integration
* Multiple accounts / unified inbox
* Very low impact on battery life
Conversations makes it very easy to create an account on the conversations.im server. Using that server comes with an annual fee of 8 Euro after a 6 month trial period. However Conversations will work with any other XMPP server as well. A lot of XMPP servers are run by volunteers and are free of charge.
XMPP Features:
Conversations works with every XMPP server out there. However XMPP is an extensible protocol. These extensions are standardized as well in so called XEPs. Conversations supports a couple of those to make the overall user experience better. There is a chance that your current XMPP server does not support these extensions. Therefore to get the most out of Conversations you should consider either switching to an XMPP server that does or - even better - run your own XMPP server for you and your friends.
These XEPs are - as of now:
* XEP-0065: SOCKS5 Bytestreams (or mod_proxy65). Will be used to transfer files if both parties are behind a firewall (NAT).
* XEP-0163: Personal Eventing Protocol for avatars
* XEP-0191: Blocking command lets you blacklist spammers or block contacts without removing them from your roster.
* XEP-0198: Stream Management allows XMPP to survive small network outages and changes of the underlying TCP connection.
* XEP-0280: Message Carbons which automatically syncs the messages you send to your desktop client and thus allows you to switch seamlessly from your mobile client to your desktop client and back within one conversation.
* XEP-0237: Roster Versioning mainly to save bandwidth on poor mobile connections
* XEP-0313: Message Archive Management synchronize message history with the server. Catch up with messages that were sent while Conversations was offline.
* XEP-0352: Client State Indication lets the server know whether or not Conversations is in the background. Allows the server to save bandwidth by withholding unimportant packages.
* XEP-0363: HTTP File Upload allows you to share files in conferences and with offline contacts. Requires an additional component on your server.

View File

@ -1,6 +1,6 @@
#Wed Apr 24 10:50:09 CEST 2019
#Thu Mar 19 11:51:26 CET 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip

View File

@ -0,0 +1,2 @@
• Audio/Video calls (Requires server support in form of STUN and TURN servers discoverable via XEP-0215)
• Rename App to only Conv6sation

1
proguard-rules.pro vendored
View File

@ -11,6 +11,7 @@
-keep class com.google.android.gms.**
-keep class org.openintents.openpgp.*
-keep class org.webrtc.** { *; }
-dontwarn org.bouncycastle.mail.**
-dontwarn org.bouncycastle.x509.util.LDAPStoreHelper

View File

@ -5,4 +5,7 @@
<string name="create_new_account">Δημιουργία νέου λογαριασμού</string>
<string name="do_you_have_an_account">Έχετε ήδη λογαριασμό XMPP; Αυτό μπορεί να συμβαίνει αν ήδη χρησιμοποιείτε ένα άλλο πρόγραμμα XMPP ή έχετε χρησιμοποιήσει το Conversations παλιότερα. Αν όχι, μπορείτε να δημιουργήσετε ένα νέο λογαριασμό XMPP τώρα.\nΧρήσιμη πληροφορία: Κάποιοι πάροχοι e-mail παρέχουν επίσης και λογαριασμούς XMPP.</string>
<string name="server_select_text">Το XMPP είναι ένα δίκτυο άμεσης ανταλλαγής μηνυμάτων ανεξάρτητο παρόχου. Μπορείτε να χρησιμοποιήσετε αυτό το πρόγραμμα με όποιον διακομιστή XMPP επιθυμείτε.\nΓια διευκόλυνση πάντως μπορείτε να δημιουργήσετε έναν λογαριασμό στο chat.sum7.eu, έναν πάροχο ειδικά σχεδιασμένο για χρήση με το Conversations.</string>
<string name="magic_create_text_on_x">Έχετε προσκληθεί στο %1$s. Ένα όνομα χρήστη έχει ήδη επιλεγεί για εσάς. Θα σας καθοδηγήσουμε στη διαδικασία δημιουργίας λογαριασμού.\nΕπιλέγοντας τον %1$s ως πάροχο θα μπορείτε να επικοινωνείτε με χρήστες άλλων παρόχων δίνοντάς τους την πλήρη διεύθυνση XMPP σας.</string>
<string name="magic_create_text_fixed">Έχετε προσκληθεί στο %1$s. Ένα όνομα χρήστη έχει ήδη επιλεγεί για εσάς. Θα σας καθοδηγήσουμε στη διαδικασία δημιουργίας λογαριασμού.\nΘα μπορείτε να επικοινωνείτε με χρήστες άλλων παρόχων δίνοντάς τους την πλήρη διεύθυνση XMPP σας.</string>
<string name="your_server_invitation">Η πρόσκλησή σας στον διακομιστή</string>
</resources>

View File

@ -3,9 +3,9 @@
<string name="pick_a_server">Choisissez votre fournisseur XMPP</string>
<string name="use_chat.sum7.eu">Utiliser chat.sum7.eu</string>
<string name="create_new_account">Créer un nouveau compte</string>
<string name="do_you_have_an_account">Avez-vous déjà un compte XMPP ? Cela peut être le cas si vous utilisez déjà un autre client XMPP ou si vous avez déjà utilisé Conversations auparavant. Sinon, vous pouvez créer un nouveau compte XMPP dès maintenant. Remarque : Certains fournisseurs de messagerie proposent également des comptes XMPP.</string>
<string name="server_select_text">XMPP est un réseau de messagerie instantanée indépendant du fournisseur. Vous pouvez utiliser ce client avec n\'importe quel serveur XMPP de votre choix. Toutefois, pour votre commodité, nous avons facilité la création d\'un compte sur chat.sum7.eu ; un fournisseur spécialement conçu pour l\'utilisation avec Conversations.</string>
<string name="magic_create_text_on_x">Vous avez été invité à %1$s. Nous vous guiderons dans le processus de création d\'un compte.\nEn choisissant %1$s comme fournisseur, vous pourrez communiquer avec les utilisateurs d\'autres fournisseurs en leur donnant votre adresse XMPP complète.</string>
<string name="magic_create_text_fixed">Vous avez été invité par %1$s . Un nom d\'utilisateur a déjà été choisi pour vous. Nous vous guiderons dans le processus de création d\'un compte. Vous pourrez communiquer avec les utilisateurs d\'autres fournisseurs en leur donnant votre adresse XMPP complète.</string>
<string name="do_you_have_an_account">Avez-vous déjà un compte XMPP? Cela peut être le cas si vous utilisez déjà un autre client XMPP ou si vous avez déjà utilisé Conversations auparavant. Sinon, vous pouvez créer un nouveau compte XMPP dès maintenant.\nRemarque : Certains fournisseurs de messagerie proposent également des comptes XMPP.</string>
<string name="server_select_text">XMPP est un réseau de messagerie instantanée indépendant du fournisseur. Vous pouvez utiliser ce client avec nimporte quel serveur XMPP de votre choix.\nToutefois, pour votre commodité, nous avons facilité la création dun compte sur chat.sum7.eu; un fournisseur spécialement conçu pour Conversations.</string>
<string name="magic_create_text_on_x">Vous avez été invité à %1$s. Nous allons vous guider à travers le processus de création dun compte.\nEn choisissant %1$s comme fournisseur, vous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète.</string>
<string name="magic_create_text_fixed">Vous avez été invité à %1$s. Un nom dutilisateur a déjà été choisi pour vous. Nous allons vous guider à travers le processus de création dun compte.\nVous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète.</string>
<string name="your_server_invitation">Votre invitation au serveur</string>
</resources>

View File

@ -5,4 +5,7 @@
<string name="create_new_account">Nieuwe account registreren</string>
<string name="do_you_have_an_account">Heb je al een XMPP-account? Als je al een andere XMPP-cliënt gebruikt, of Conversations vroeger al eens hebt gebruikt, is dit waarschijnlijk het geval. Zo niet, kan je nu een nieuwe XMPP-account aanmaken.\nTip: sommige e-mailproviders bieden ook XMPP-accounts aan.</string>
<string name="server_select_text">XMPP is een provider-onafhankelijk berichtennetwerk. Je kan deze cliënt gebruiken met eender welke XMPP-server.\nOm het je gemakkelijker te maken kun je simpelweg een account aanmaken op chat.sum7.eu; een provider speciaal geschikt voor Conversations.</string>
<string name="magic_create_text_on_x">Je ontving een uitnodiging voor %1$s. We zullen je helpen een account aan te maken.\nWanneer je %1$s als je provider kiest kan je met gebruikers van andere providers communiceren door hen je volledige XMPP-adres te geven.</string>
<string name="magic_create_text_fixed">Je ontving een uitnodiging voor %1$s. Er werd reeds een gebruikersnaam voor jou gekozen. We zullen je helpen een account aan te maken.\nJe zal met gebruikers van andere providers communiceren door hen je volledige XMPP-adres te geven.</string>
<string name="your_server_invitation">Je server uitnodiging</string>
</resources>

View File

@ -5,4 +5,7 @@
<string name="create_new_account">Створити новий обліковий запис</string>
<string name="do_you_have_an_account">Вже маєте обліковий запис XMPP? Можливо, користуєтеся іншою програмою XMPP або користувалися цією програмою раніше. Якщо ні, можете створити новий обліковий запис XMPP просто зараз.\nЗверніть увагу: Деякі постачальники електронної пошти водночас надають облікові записи XMPP.</string>
<string name="server_select_text">XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP сервером, який оберете.\nПроте, для зручності, ми спростили створення облікового запису на chat.sum7.eu — в постачальника, який спеціально налаштований на роботу з цією програмою.</string>
<string name="magic_create_text_on_x">Вас запросили до %1$s. Ми проведемо Вас крок за кроком, щоб створити обліковий запис.\nОбираючи %1$s в якості свого постачальника, Ви зможете спілкуватися з користувачами інших постачальників, повідомивши їм свою повну адресу XMPP.</string>
<string name="magic_create_text_fixed">Вас запросили до %1$s. Для Вас уже обрали ім\'я користувача. Ми проведемо Вас крок за кроком, щоб створити обліковий запис.\nВи зможете спілкуватися з користувачами інших постачальників, повідомивши їм свою повну адресу XMPP.</string>
<string name="your_server_invitation">Ваше запрошення до сервера</string>
</resources>

View File

@ -31,6 +31,10 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission
android:name="android.permission.READ_PHONE_STATE"
@ -51,8 +55,9 @@
<application
android:allowBackup="true"
android:allowBackup="false"
android:appCategory="social"
android:hardwareAccelerated="true"
android:icon="@mipmap/new_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_configuration"
@ -220,9 +225,10 @@
<data android:mimeType="*/*" />
</intent-filter>
<!-- the value here needs to be the full class name; independent of the configured applicationId -->
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value=".services.ContactChooserTargetService" />
android:value="eu.siacs.conversations.services.ContactChooserTargetService" />
</activity>
<activity
android:name=".ui.TrustKeysActivity"
@ -286,6 +292,11 @@
<activity
android:name=".ui.ChannelDiscoveryActivity"
android:label="@string/discover_channels" />
<activity
android:name=".ui.RtpSessionActivity"
android:autoRemoveFromRecents="true"
android:launchMode="singleInstance"
android:supportsPictureInPicture="true" />
</application>
</manifest>

View File

@ -733,6 +733,18 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
}
}
public Message findRtpSession(final String sessionId, final int s) {
synchronized (this.messages) {
for (int i = this.messages.size() - 1; i >= 0; --i) {
final Message message = this.messages.get(i);
if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
return message;
}
}
}
return null;
}
public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
if (serverMsgId == null || remoteMsgId == null) {
return false;
@ -1007,7 +1019,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return UIHelper.getColorForName(getName().toString());
}
public interface OnMessageFound {
public interface OnMessageFound {
void onMessageFound(final Message message);
}

View File

@ -57,6 +57,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
public static final int TYPE_STATUS = 3;
public static final int TYPE_PRIVATE = 4;
public static final int TYPE_PRIVATE_FILE = 5;
public static final int TYPE_RTP_SESSION = 6;
public static final String CONVERSATION = "conversationUuid";
public static final String COUNTERPART = "counterpart";
@ -151,6 +152,31 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
null);
}
public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
this(conversation, java.util.UUID.randomUUID().toString(),
conversation.getUuid(),
conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
null,
null,
System.currentTimeMillis(),
Message.ENCRYPTION_NONE,
status,
type,
false,
remoteMsgId,
null,
null,
null,
true,
null,
false,
null,
null,
false,
false,
null);
}
protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
final Jid trueCounterpart, final String body, final long timeSent,
final int encryption, final int status, final int type, final boolean carbon,

View File

@ -0,0 +1,59 @@
package eu.siacs.conversations.entities;
import android.support.annotation.DrawableRes;
import com.google.common.base.Strings;
import eu.siacs.conversations.R;
public class RtpSessionStatus {
public final boolean successful;
public final long duration;
public RtpSessionStatus(boolean successful, long duration) {
this.successful = successful;
this.duration = duration;
}
@Override
public String toString() {
return successful + ":" + duration;
}
public static RtpSessionStatus of(final String body) {
final String[] parts = Strings.nullToEmpty(body).split(":", 2);
long duration = 0;
if (parts.length == 2) {
try {
duration = Long.parseLong(parts[1]);
} catch (NumberFormatException e) {
//do nothing
}
}
boolean made;
try {
made = Boolean.parseBoolean(parts[0]);
} catch (Exception e) {
made = false;
}
return new RtpSessionStatus(made, duration);
}
public static @DrawableRes int getDrawable(final boolean received, final boolean successful, final boolean darkTheme) {
if (received) {
if (successful) {
return darkTheme ? R.drawable.ic_call_received_white_18dp : R.drawable.ic_call_received_black_18dp;
} else {
return darkTheme ? R.drawable.ic_call_missed_white_18dp : R.drawable.ic_call_missed_black_18dp;
}
} else {
if (successful) {
return darkTheme ? R.drawable.ic_call_made_white_18dp : R.drawable.ic_call_made_black_18dp;
} else {
return darkTheme ? R.drawable.ic_call_missed_outgoing_white_18dp : R.drawable.ic_call_missed_outgoing_black_18dp;
}
}
}
}

View File

@ -6,6 +6,8 @@ import android.support.annotation.NonNull;
import android.util.Base64;
import android.util.Log;
import com.google.common.base.Strings;
import java.io.UnsupportedEncodingException;
import java.lang.Comparable;
import java.security.MessageDigest;
@ -222,9 +224,9 @@ public class ServiceDiscoveryResult {
for (Data form : forms) {
s.append(clean(form.getFormType())).append("<");
List<Field> fields = form.getFields();
Collections.sort(fields, (lhs, rhs) -> lhs.getFieldName().compareTo(rhs.getFieldName()));
Collections.sort(fields, (lhs, rhs) -> Strings.nullToEmpty(lhs.getFieldName()).compareTo(Strings.nullToEmpty(rhs.getFieldName())));
for (Field field : fields) {
s.append(clean(field.getFieldName())).append("<");
s.append(Strings.nullToEmpty(field.getFieldName())).append("<");
List<String> values = field.getValues();
Collections.sort(values);
for (String value : values) {

View File

@ -20,119 +20,128 @@ import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.PhoneHelper;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.XmppConnection;
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
public abstract class AbstractGenerator {
private final String[] FEATURES = {
"urn:xmpp:jingle:1",
Content.Version.FT_3.getNamespace(),
Content.Version.FT_4.getNamespace(),
Content.Version.FT_5.getNamespace(),
Namespace.JINGLE_TRANSPORTS_S5B,
Namespace.JINGLE_TRANSPORTS_IBB,
Namespace.JINGLE_ENCRYPTED_TRANSPORT,
Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
"http://jabber.org/protocol/muc",
"jabber:x:conference",
Namespace.OOB,
"http://jabber.org/protocol/caps",
"http://jabber.org/protocol/disco#info",
"urn:xmpp:avatar:metadata+notify",
Namespace.NICK+"+notify",
"urn:xmpp:ping",
"jabber:iq:version",
"http://jabber.org/protocol/chatstates"
};
private final String[] MESSAGE_CONFIRMATION_FEATURES = {
"urn:xmpp:chat-markers:0",
"urn:xmpp:receipts"
};
private final String[] MESSAGE_CORRECTION_FEATURES = {
"urn:xmpp:message-correct:0"
};
private final String[] PRIVACY_SENSITIVE = {
"urn:xmpp:time" //XEP-0202: Entity Time leaks time zone
};
private String mVersion = null;
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
private final String[] FEATURES = {
Namespace.JINGLE,
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
//Jingle File Transfer
FileTransferDescription.Version.FT_3.getNamespace(),
FileTransferDescription.Version.FT_4.getNamespace(),
FileTransferDescription.Version.FT_5.getNamespace(),
Namespace.JINGLE_TRANSPORTS_S5B,
Namespace.JINGLE_TRANSPORTS_IBB,
Namespace.JINGLE_ENCRYPTED_TRANSPORT,
Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
"http://jabber.org/protocol/muc",
"jabber:x:conference",
Namespace.OOB,
"http://jabber.org/protocol/caps",
"http://jabber.org/protocol/disco#info",
"urn:xmpp:avatar:metadata+notify",
Namespace.NICK + "+notify",
"urn:xmpp:ping",
"jabber:iq:version",
"http://jabber.org/protocol/chatstates"
};
private final String[] MESSAGE_CONFIRMATION_FEATURES = {
"urn:xmpp:chat-markers:0",
"urn:xmpp:receipts"
};
private final String[] MESSAGE_CORRECTION_FEATURES = {
"urn:xmpp:message-correct:0"
};
private final String[] PRIVACY_SENSITIVE = {
"urn:xmpp:time" //XEP-0202: Entity Time leaks time zone
};
private final String[] VOIP_NAMESPACES = {
Namespace.JINGLE_TRANSPORT_ICE_UDP,
Namespace.JINGLE_FEATURE_AUDIO,
Namespace.JINGLE_FEATURE_VIDEO,
Namespace.JINGLE_APPS_RTP,
Namespace.JINGLE_APPS_DTLS,
Namespace.JINGLE_MESSAGE
};
protected XmppConnectionService mXmppConnectionService;
private String mVersion = null;
protected XmppConnectionService mXmppConnectionService;
AbstractGenerator(XmppConnectionService service) {
this.mXmppConnectionService = service;
}
AbstractGenerator(XmppConnectionService service) {
this.mXmppConnectionService = service;
}
public static String getTimestamp(long time) {
DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
return DATE_FORMAT.format(time);
}
String getIdentityVersion() {
if (mVersion == null) {
this.mVersion = PhoneHelper.getVersionName(mXmppConnectionService);
}
return this.mVersion;
}
String getIdentityVersion() {
if (mVersion == null) {
this.mVersion = PhoneHelper.getVersionName(mXmppConnectionService);
}
return this.mVersion;
}
String getIdentityName() {
return mXmppConnectionService.getString(R.string.app_name);
}
String getIdentityName() {
return mXmppConnectionService.getString(R.string.app_name);
}
public String getUserAgent() {
return mXmppConnectionService.getString(R.string.app_name) + '/' + getIdentityVersion();
}
public String getUserAgent() {
return mXmppConnectionService.getString(R.string.app_name) + '/' + getIdentityVersion();
}
String getIdentityType() {
if ("chromium".equals(android.os.Build.BRAND)) {
return "pc";
} else {
return mXmppConnectionService.getString(R.string.default_resource).toLowerCase();
}
}
String getIdentityType() {
if ("chromium".equals(android.os.Build.BRAND)) {
return "pc";
} else {
return mXmppConnectionService.getString(R.string.default_resource).toLowerCase();
}
}
String getCapHash(final Account account) {
StringBuilder s = new StringBuilder();
s.append("client/").append(getIdentityType()).append("//").append(getIdentityName()).append('<');
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
return null;
}
String getCapHash(final Account account) {
StringBuilder s = new StringBuilder();
s.append("client/").append(getIdentityType()).append("//").append(getIdentityName()).append('<');
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
return null;
}
for (String feature : getFeatures(account)) {
s.append(feature).append('<');
}
final byte[] sha1 = md.digest(s.toString().getBytes());
return Base64.encodeToString(sha1, Base64.NO_WRAP);
}
for (String feature : getFeatures(account)) {
s.append(feature).append('<');
}
final byte[] sha1 = md.digest(s.toString().getBytes());
return Base64.encodeToString(sha1, Base64.NO_WRAP);
}
public static String getTimestamp(long time) {
DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
return DATE_FORMAT.format(time);
}
public List<String> getFeatures(Account account) {
final XmppConnection connection = account.getXmppConnection();
final ArrayList<String> features = new ArrayList<>(Arrays.asList(FEATURES));
if (mXmppConnectionService.confirmMessages()) {
features.addAll(Arrays.asList(MESSAGE_CONFIRMATION_FEATURES));
}
if (mXmppConnectionService.allowMessageCorrection()) {
features.addAll(Arrays.asList(MESSAGE_CORRECTION_FEATURES));
}
if (Config.supportOmemo()) {
features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
}
if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) {
features.addAll(Arrays.asList(PRIVACY_SENSITIVE));
features.addAll(Arrays.asList(VOIP_NAMESPACES));
}
if (mXmppConnectionService.broadcastLastActivity()) {
features.add(Namespace.IDLE);
}
if (connection != null && connection.getFeatures().bookmarks2()) {
features.add(Namespace.BOOKMARKS2 + "+notify");
} else {
features.add(Namespace.BOOKMARKS + "+notify");
}
public List<String> getFeatures(Account account) {
final XmppConnection connection = account.getXmppConnection();
final ArrayList<String> features = new ArrayList<>(Arrays.asList(FEATURES));
if (mXmppConnectionService.confirmMessages()) {
features.addAll(Arrays.asList(MESSAGE_CONFIRMATION_FEATURES));
}
if (mXmppConnectionService.allowMessageCorrection()) {
features.addAll(Arrays.asList(MESSAGE_CORRECTION_FEATURES));
}
if (Config.supportOmemo()) {
features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
}
if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) {
features.addAll(Arrays.asList(PRIVACY_SENSITIVE));
}
if (mXmppConnectionService.broadcastLastActivity()) {
features.add(Namespace.IDLE);
}
if (connection != null && connection.getFeatures().bookmarks2()) {
features.add(Namespace.BOOKMARKS2 +"+notify");
} else {
features.add(Namespace.BOOKMARKS+"+notify");
}
Collections.sort(features);
return features;
}
Collections.sort(features);
return features;
}
}

View File

@ -18,217 +18,258 @@ import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
import rocks.xmpp.addr.Jid;
public class MessageGenerator extends AbstractGenerator {
private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesnt seem to support that. Find more information on https://conversations.im/omemo";
private static final String PGP_FALLBACK_MESSAGE = "I sent you a PGP encrypted message but your client doesnt seem to support that.";
private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesnt seem to support that. Find more information on https://conversations.im/omemo";
private static final String PGP_FALLBACK_MESSAGE = "I sent you a PGP encrypted message but your client doesnt seem to support that.";
public MessageGenerator(XmppConnectionService service) {
super(service);
}
public MessageGenerator(XmppConnectionService service) {
super(service);
}
private MessagePacket preparePacket(Message message) {
Conversation conversation = (Conversation) message.getConversation();
Account account = conversation.getAccount();
MessagePacket packet = new MessagePacket();
final boolean isWithSelf = conversation.getContact().isSelf();
if (conversation.getMode() == Conversation.MODE_SINGLE) {
packet.setTo(message.getCounterpart());
packet.setType(MessagePacket.TYPE_CHAT);
if (!isWithSelf) {
packet.addChild("request", "urn:xmpp:receipts");
}
} else if (message.isPrivateMessage()) {
packet.setTo(message.getCounterpart());
packet.setType(MessagePacket.TYPE_CHAT);
packet.addChild("x", "http://jabber.org/protocol/muc#user");
packet.addChild("request", "urn:xmpp:receipts");
} else {
packet.setTo(message.getCounterpart().asBareJid());
packet.setType(MessagePacket.TYPE_GROUPCHAT);
}
if (conversation.isSingleOrPrivateAndNonAnonymous() && !message.isPrivateMessage()) {
packet.addChild("markable", "urn:xmpp:chat-markers:0");
}
packet.setFrom(account.getJid());
packet.setId(message.getUuid());
packet.addChild("origin-id", Namespace.STANZA_IDS).setAttribute("id", message.getUuid());
if (message.edited()) {
packet.addChild("replace", "urn:xmpp:message-correct:0").setAttribute("id", message.getEditedIdWireFormat());
}
return packet;
}
private MessagePacket preparePacket(Message message) {
Conversation conversation = (Conversation) message.getConversation();
Account account = conversation.getAccount();
MessagePacket packet = new MessagePacket();
final boolean isWithSelf = conversation.getContact().isSelf();
if (conversation.getMode() == Conversation.MODE_SINGLE) {
packet.setTo(message.getCounterpart());
packet.setType(MessagePacket.TYPE_CHAT);
if (!isWithSelf) {
packet.addChild("request", "urn:xmpp:receipts");
}
} else if (message.isPrivateMessage()) {
packet.setTo(message.getCounterpart());
packet.setType(MessagePacket.TYPE_CHAT);
packet.addChild("x", "http://jabber.org/protocol/muc#user");
packet.addChild("request", "urn:xmpp:receipts");
} else {
packet.setTo(message.getCounterpart().asBareJid());
packet.setType(MessagePacket.TYPE_GROUPCHAT);
}
if (conversation.isSingleOrPrivateAndNonAnonymous() && !message.isPrivateMessage()) {
packet.addChild("markable", "urn:xmpp:chat-markers:0");
}
packet.setFrom(account.getJid());
packet.setId(message.getUuid());
packet.addChild("origin-id", Namespace.STANZA_IDS).setAttribute("id", message.getUuid());
if (message.edited()) {
packet.addChild("replace", "urn:xmpp:message-correct:0").setAttribute("id", message.getEditedIdWireFormat());
}
return packet;
}
public void addDelay(MessagePacket packet, long timestamp) {
final SimpleDateFormat mDateFormat = new SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
Element delay = packet.addChild("delay", "urn:xmpp:delay");
Date date = new Date(timestamp);
delay.setAttribute("stamp", mDateFormat.format(date));
}
public void addDelay(MessagePacket packet, long timestamp) {
final SimpleDateFormat mDateFormat = new SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
Element delay = packet.addChild("delay", "urn:xmpp:delay");
Date date = new Date(timestamp);
delay.setAttribute("stamp", mDateFormat.format(date));
}
public MessagePacket generateAxolotlChat(Message message, XmppAxolotlMessage axolotlMessage) {
MessagePacket packet = preparePacket(message);
if (axolotlMessage == null) {
return null;
}
packet.setAxolotlMessage(axolotlMessage.toElement());
packet.setBody(OMEMO_FALLBACK_MESSAGE);
packet.addChild("store", "urn:xmpp:hints");
packet.addChild("encryption", "urn:xmpp:eme:0")
.setAttribute("name", "OMEMO")
.setAttribute("namespace", AxolotlService.PEP_PREFIX);
return packet;
}
public MessagePacket generateAxolotlChat(Message message, XmppAxolotlMessage axolotlMessage) {
MessagePacket packet = preparePacket(message);
if (axolotlMessage == null) {
return null;
}
packet.setAxolotlMessage(axolotlMessage.toElement());
packet.setBody(OMEMO_FALLBACK_MESSAGE);
packet.addChild("store", "urn:xmpp:hints");
packet.addChild("encryption", "urn:xmpp:eme:0")
.setAttribute("name", "OMEMO")
.setAttribute("namespace", AxolotlService.PEP_PREFIX);
return packet;
}
public MessagePacket generateKeyTransportMessage(Jid to, XmppAxolotlMessage axolotlMessage) {
MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_CHAT);
packet.setTo(to);
packet.setAxolotlMessage(axolotlMessage.toElement());
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public MessagePacket generateKeyTransportMessage(Jid to, XmppAxolotlMessage axolotlMessage) {
MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_CHAT);
packet.setTo(to);
packet.setAxolotlMessage(axolotlMessage.toElement());
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public MessagePacket generateChat(Message message) {
MessagePacket packet = preparePacket(message);
String content;
if (message.hasFileOnRemoteHost()) {
Message.FileParams fileParams = message.getFileParams();
final URL url = fileParams.url;
if (P1S3UrlStreamHandler.PROTOCOL_NAME.equals(url.getProtocol())) {
Element x = packet.addChild("x", Namespace.P1_S3_FILE_TRANSFER);
final String file = url.getFile();
x.setAttribute("name", file.charAt(0) == '/' ? file.substring(1) : file);
x.setAttribute("fileid", url.getHost());
return packet;
} else {
content = url.toString();
packet.addChild("x", Namespace.OOB).addChild("url").setContent(content);
}
} else {
content = message.getBody();
}
packet.setBody(content);
return packet;
}
public MessagePacket generateChat(Message message) {
MessagePacket packet = preparePacket(message);
String content;
if (message.hasFileOnRemoteHost()) {
Message.FileParams fileParams = message.getFileParams();
final URL url = fileParams.url;
if (P1S3UrlStreamHandler.PROTOCOL_NAME.equals(url.getProtocol())) {
Element x = packet.addChild("x", Namespace.P1_S3_FILE_TRANSFER);
final String file = url.getFile();
x.setAttribute("name", file.charAt(0) == '/' ? file.substring(1) : file);
x.setAttribute("fileid", url.getHost());
return packet;
} else {
content = url.toString();
packet.addChild("x", Namespace.OOB).addChild("url").setContent(content);
}
} else {
content = message.getBody();
}
packet.setBody(content);
return packet;
}
public MessagePacket generatePgpChat(Message message) {
MessagePacket packet = preparePacket(message);
if (message.hasFileOnRemoteHost()) {
Message.FileParams fileParams = message.getFileParams();
final URL url = fileParams.url;
if (P1S3UrlStreamHandler.PROTOCOL_NAME.equals(url.getProtocol())) {
Element x = packet.addChild("x", Namespace.P1_S3_FILE_TRANSFER);
final String file = url.getFile();
x.setAttribute("name", file.charAt(0) == '/' ? file.substring(1) : file);
x.setAttribute("fileid", url.getHost());
} else {
packet.setBody(url.toString());
packet.addChild("x", Namespace.OOB).addChild("url").setContent(url.toString());
}
} else {
if (Config.supportUnencrypted()) {
packet.setBody(PGP_FALLBACK_MESSAGE);
}
if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
packet.addChild("x", "jabber:x:encrypted").setContent(message.getEncryptedBody());
} else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
packet.addChild("x", "jabber:x:encrypted").setContent(message.getBody());
}
packet.addChild("encryption", "urn:xmpp:eme:0")
.setAttribute("namespace", "jabber:x:encrypted");
}
return packet;
}
public MessagePacket generatePgpChat(Message message) {
MessagePacket packet = preparePacket(message);
if (message.hasFileOnRemoteHost()) {
Message.FileParams fileParams = message.getFileParams();
final URL url = fileParams.url;
if (P1S3UrlStreamHandler.PROTOCOL_NAME.equals(url.getProtocol())) {
Element x = packet.addChild("x", Namespace.P1_S3_FILE_TRANSFER);
final String file = url.getFile();
x.setAttribute("name", file.charAt(0) == '/' ? file.substring(1) : file);
x.setAttribute("fileid", url.getHost());
} else {
packet.setBody(url.toString());
packet.addChild("x", Namespace.OOB).addChild("url").setContent(url.toString());
}
} else {
if (Config.supportUnencrypted()) {
packet.setBody(PGP_FALLBACK_MESSAGE);
}
if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
packet.addChild("x", "jabber:x:encrypted").setContent(message.getEncryptedBody());
} else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
packet.addChild("x", "jabber:x:encrypted").setContent(message.getBody());
}
packet.addChild("encryption", "urn:xmpp:eme:0")
.setAttribute("namespace", "jabber:x:encrypted");
}
return packet;
}
public MessagePacket generateChatState(Conversation conversation) {
final Account account = conversation.getAccount();
MessagePacket packet = new MessagePacket();
packet.setType(conversation.getMode() == Conversation.MODE_MULTI ? MessagePacket.TYPE_GROUPCHAT : MessagePacket.TYPE_CHAT);
packet.setTo(conversation.getJid().asBareJid());
packet.setFrom(account.getJid());
packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
packet.addChild("no-store", "urn:xmpp:hints");
packet.addChild("no-storage", "urn:xmpp:hints"); //wrong! don't copy this. Its *store*
return packet;
}
public MessagePacket generateChatState(Conversation conversation) {
final Account account = conversation.getAccount();
MessagePacket packet = new MessagePacket();
packet.setType(conversation.getMode() == Conversation.MODE_MULTI ? MessagePacket.TYPE_GROUPCHAT : MessagePacket.TYPE_CHAT);
packet.setTo(conversation.getJid().asBareJid());
packet.setFrom(account.getJid());
packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
packet.addChild("no-store", "urn:xmpp:hints");
packet.addChild("no-storage", "urn:xmpp:hints"); //wrong! don't copy this. Its *store*
return packet;
}
public MessagePacket confirm(final Account account, final Jid to, final String id, final Jid counterpart, final boolean groupChat) {
MessagePacket packet = new MessagePacket();
packet.setType(groupChat ? MessagePacket.TYPE_GROUPCHAT : MessagePacket.TYPE_CHAT);
packet.setTo(groupChat ? to.asBareJid() : to);
packet.setFrom(account.getJid());
Element displayed = packet.addChild("displayed", "urn:xmpp:chat-markers:0");
displayed.setAttribute("id", id);
if (groupChat && counterpart != null) {
displayed.setAttribute("sender", counterpart.toString());
}
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public MessagePacket confirm(final Account account, final Jid to, final String id, final Jid counterpart, final boolean groupChat) {
MessagePacket packet = new MessagePacket();
packet.setType(groupChat ? MessagePacket.TYPE_GROUPCHAT : MessagePacket.TYPE_CHAT);
packet.setTo(groupChat ? to.asBareJid() : to);
packet.setFrom(account.getJid());
Element displayed = packet.addChild("displayed", "urn:xmpp:chat-markers:0");
displayed.setAttribute("id", id);
if (groupChat && counterpart != null) {
displayed.setAttribute("sender", counterpart.toString());
}
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public MessagePacket conferenceSubject(Conversation conversation, String subject) {
MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_GROUPCHAT);
packet.setTo(conversation.getJid().asBareJid());
packet.addChild("subject").setContent(subject);
packet.setFrom(conversation.getAccount().getJid().asBareJid());
return packet;
}
public MessagePacket conferenceSubject(Conversation conversation, String subject) {
MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_GROUPCHAT);
packet.setTo(conversation.getJid().asBareJid());
packet.addChild("subject").setContent(subject);
packet.setFrom(conversation.getAccount().getJid().asBareJid());
return packet;
}
public MessagePacket directInvite(final Conversation conversation, final Jid contact) {
MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_NORMAL);
packet.setTo(contact);
packet.setFrom(conversation.getAccount().getJid());
Element x = packet.addChild("x", "jabber:x:conference");
x.setAttribute("jid", conversation.getJid().asBareJid().toString());
String password = conversation.getMucOptions().getPassword();
if (password != null) {
x.setAttribute("password", password);
}
if (contact.isFullJid()) {
packet.addChild("no-store", "urn:xmpp:hints");
packet.addChild("no-copy", "urn:xmpp:hints");
}
return packet;
}
public MessagePacket directInvite(final Conversation conversation, final Jid contact) {
MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_NORMAL);
packet.setTo(contact);
packet.setFrom(conversation.getAccount().getJid());
Element x = packet.addChild("x", "jabber:x:conference");
x.setAttribute("jid", conversation.getJid().asBareJid().toString());
String password = conversation.getMucOptions().getPassword();
if (password != null) {
x.setAttribute("password", password);
}
if (contact.isFullJid()) {
packet.addChild("no-store", "urn:xmpp:hints");
packet.addChild("no-copy", "urn:xmpp:hints");
}
return packet;
}
public MessagePacket invite(Conversation conversation, Jid contact) {
MessagePacket packet = new MessagePacket();
packet.setTo(conversation.getJid().asBareJid());
packet.setFrom(conversation.getAccount().getJid());
Element x = new Element("x");
x.setAttribute("xmlns", "http://jabber.org/protocol/muc#user");
Element invite = new Element("invite");
invite.setAttribute("to", contact.asBareJid().toString());
x.addChild(invite);
packet.addChild(x);
return packet;
}
public MessagePacket invite(Conversation conversation, Jid contact) {
MessagePacket packet = new MessagePacket();
packet.setTo(conversation.getJid().asBareJid());
packet.setFrom(conversation.getAccount().getJid());
Element x = new Element("x");
x.setAttribute("xmlns", "http://jabber.org/protocol/muc#user");
Element invite = new Element("invite");
invite.setAttribute("to", contact.asBareJid().toString());
x.addChild(invite);
packet.addChild(x);
return packet;
}
public MessagePacket received(Account account, MessagePacket originalMessage, ArrayList<String> namespaces, int type) {
MessagePacket receivedPacket = new MessagePacket();
receivedPacket.setType(type);
receivedPacket.setTo(originalMessage.getFrom());
receivedPacket.setFrom(account.getJid());
for (String namespace : namespaces) {
receivedPacket.addChild("received", namespace).setAttribute("id", originalMessage.getId());
}
receivedPacket.addChild("store", "urn:xmpp:hints");
return receivedPacket;
}
public MessagePacket received(Account account, MessagePacket originalMessage, ArrayList<String> namespaces, int type) {
MessagePacket receivedPacket = new MessagePacket();
receivedPacket.setType(type);
receivedPacket.setTo(originalMessage.getFrom());
receivedPacket.setFrom(account.getJid());
for (String namespace : namespaces) {
receivedPacket.addChild("received", namespace).setAttribute("id", originalMessage.getId());
}
receivedPacket.addChild("store", "urn:xmpp:hints");
return receivedPacket;
}
public MessagePacket received(Account account, Jid to, String id) {
MessagePacket packet = new MessagePacket();
packet.setFrom(account.getJid());
packet.setTo(to);
packet.addChild("received", "urn:xmpp:receipts").setAttribute("id", id);
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public MessagePacket received(Account account, Jid to, String id) {
MessagePacket packet = new MessagePacket();
packet.setFrom(account.getJid());
packet.setTo(to);
packet.addChild("received", "urn:xmpp:receipts").setAttribute("id", id);
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public MessagePacket sessionProposal(final JingleConnectionManager.RtpSessionProposal proposal) {
final MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.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 MessagePacket sessionRetract(final JingleConnectionManager.RtpSessionProposal proposal) {
final MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.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 MessagePacket sessionReject(final Jid with, final String sessionId) {
final MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.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

@ -6,6 +6,7 @@ import android.util.Pair;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@ -30,6 +31,7 @@ import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.ReadByMarker;
import eu.siacs.conversations.entities.ReceiptRequest;
import eu.siacs.conversations.entities.RtpSessionStatus;
import eu.siacs.conversations.http.HttpConnectionManager;
import eu.siacs.conversations.http.P1S3UrlStreamHandler;
import eu.siacs.conversations.services.MessageArchiveService;
@ -42,6 +44,8 @@ import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xmpp.InvalidJid;
import eu.siacs.conversations.xmpp.OnMessagePacketReceived;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.pep.Avatar;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
import rocks.xmpp.addr.Jid;
@ -50,6 +54,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss", Locale.ENGLISH);
private static final List<String> JINGLE_MESSAGE_ELEMENT_NAMES = Arrays.asList("accept", "propose", "proceed", "reject", "retract");
public MessageParser(XmppConnectionService service) {
super(service);
}
@ -68,6 +74,11 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
return safeToExtract ? extractStanzaId(packet, by) : null;
}
private static String extractStanzaId(Account account, Element packet) {
final boolean safeToExtract = account.getXmppConnection().getFeatures().stanzaIds();
return safeToExtract ? extractStanzaId(packet, account.getJid().asBareJid()) : null;
}
private static String extractStanzaId(Element packet, Jid by) {
for (Element child : packet.getChildren()) {
if (child.getName().equals("stanza-id")
@ -136,7 +147,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
return new Message(conversation, "", Message.ENCRYPTION_AXOLOTL_FAILED, status);
}
} else {
Log.d(Config.LOGTAG,"ignoring broken session exception because checkForDuplicates failed");
Log.d(Config.LOGTAG, "ignoring broken session exception because checkForDuplicates failed");
return null;
}
} catch (NotEncryptedForThisDeviceException e) {
@ -249,13 +260,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final Jid id = InvalidJid.getNullForInvalid(retract.getAttributeAsJid("id"));
if (id != null) {
account.removeBookmark(id);
Log.d(Config.LOGTAG,account.getJid().asBareJid()+": deleted bookmark for "+id);
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmark for " + id);
mXmppConnectionService.processDeletedBookmark(account, id);
mXmppConnectionService.updateConversationUi();
}
}
} else {
Log.d(Config.LOGTAG,account.getJid().asBareJid()+" received pubsub notification for node="+node);
Log.d(Config.LOGTAG, account.getJid().asBareJid() + " received pubsub notification for node=" + node);
}
}
@ -267,7 +278,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
setNick(account, from, null);
} else if (Namespace.BOOKMARKS2.equals(node) && account.getJid().asBareJid().equals(from)) {
account.setBookmarks(Collections.emptyMap());
Log.d(Config.LOGTAG,account.getJid().asBareJid()+": deleted bookmarks node");
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmarks node");
}
}
@ -276,7 +287,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final String node = purge == null ? null : purge.getAttribute("node");
if (Namespace.BOOKMARKS2.equals(node) && account.getJid().asBareJid().equals(from)) {
account.setBookmarks(Collections.emptyMap());
Log.d(Config.LOGTAG,account.getJid().asBareJid()+": purged bookmarks");
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": purged bookmarks");
}
}
@ -298,20 +309,32 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
private boolean handleErrorMessage(Account account, MessagePacket packet) {
if (packet.getType() == MessagePacket.TYPE_ERROR) {
Jid from = packet.getFrom();
if (from != null) {
final Jid from = packet.getFrom();
final String id = packet.getId();
if (from != null && id != null) {
if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX)) {
final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX.length());
mXmppConnectionService.getJingleConnectionManager()
.updateProposedSessionDiscovered(account, from, sessionId, JingleConnectionManager.DeviceDiscoveryState.FAILED);
return true;
}
if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX)) {
final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX.length());
mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId);
return true;
}
mXmppConnectionService.markMessage(account,
from.asBareJid(),
packet.getId(),
id,
Message.STATUS_SEND_FAILED,
extractErrorMessage(packet));
final Element error = packet.findChild("error");
final boolean pingWorthyError = error != null && (error.hasChild("not-acceptable") || error.hasChild("remote-server-timeout") || error.hasChild("remote-server-not-found"));
if (pingWorthyError) {
Conversation conversation = mXmppConnectionService.find(account,from);
Conversation conversation = mXmppConnectionService.find(account, from);
if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI) {
if (conversation.getMucOptions().online()) {
Log.d(Config.LOGTAG,account.getJid().asBareJid()+": received ping worthy error for seemingly online muc at "+from);
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ping worthy error for seemingly online muc at " + from);
mXmppConnectionService.mucSelfPingAndRejoin(conversation);
}
}
@ -419,9 +442,9 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final Invite invite = extractInvite(packet);
if (invite != null) {
if (isTypeGroupChat) {
Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignoring invite to "+invite.jid+" because type=groupchat");
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring invite to " + invite.jid + " because type=groupchat");
} else if (invite.direct && (mucUserElement != null || invite.inviter == null || mXmppConnectionService.isMuc(account, invite.inviter))) {
Log.d(Config.LOGTAG, account.getJid().asBareJid()+": ignoring direct invite to "+invite.jid+" because it was received in MUC");
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring direct invite to " + invite.jid + " because it was received in MUC");
} else {
invite.execute(account);
return;
@ -504,7 +527,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final boolean checkedForDuplicates = liveMessage || (serverMsgId != null && remoteMsgId != null && !conversation.possibleDuplicate(serverMsgId, remoteMsgId));
if (origin != null) {
message = parseAxolotlChat(axolotlEncrypted, origin, conversation, status, checkedForDuplicates,query != null);
message = parseAxolotlChat(axolotlEncrypted, origin, conversation, status, checkedForDuplicates, query != null);
} else {
Message trial = null;
for (Jid fallback : fallbacksBySourceId) {
@ -809,6 +832,69 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
}
}
if (!isTypeGroupChat) {
for (Element child : packet.getChildren()) {
if (Namespace.JINGLE_MESSAGE.equals(child.getNamespace()) && JINGLE_MESSAGE_ELEMENT_NAMES.contains(child.getName())) {
final String action = child.getName();
if (query == null) {
if (!account.getJid().asBareJid().equals(from.asBareJid())) {
processMessageReceipts(account, packet, query);
}
if (serverMsgId == null) {
serverMsgId = extractStanzaId(account, packet);
}
mXmppConnectionService.getJingleConnectionManager().deliverMessage(account, packet.getTo(), packet.getFrom(), child, serverMsgId, timestamp);
} else if (query.isCatchup()) {
final String sessionId = child.getAttribute("id");
if (sessionId == null) {
break;
}
if ("propose".equals(action)) {
final Element description = child.findChild("description");
final String namespace = description == null ? null : description.getNamespace();
if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
final Conversation c = mXmppConnectionService.findOrCreateConversation(account, counterpart.asBareJid(), false, false);
final Message preExistingMessage = c.findRtpSession(sessionId, status);
if (preExistingMessage != null) {
preExistingMessage.setServerMsgId(serverMsgId);
mXmppConnectionService.updateMessage(preExistingMessage);
break;
}
final Message message = new Message(
c,
status,
Message.TYPE_RTP_SESSION,
sessionId
);
message.setServerMsgId(serverMsgId);
message.setTime(timestamp);
message.setBody(new RtpSessionStatus(false, 0).toString());
c.add(message);
mXmppConnectionService.databaseBackend.createMessage(message);
}
} else if ("proceed".equals(action)) {
//status needs to be flipped to find the original propose
final Conversation c = mXmppConnectionService.findOrCreateConversation(account, counterpart.asBareJid(), false, false);
final int s = packet.fromAccount(account) ? Message.STATUS_RECEIVED : Message.STATUS_SEND;
final Message message = c.findRtpSession(sessionId, s);
if (message != null) {
message.setBody(new RtpSessionStatus(true, 0).toString());
if (serverMsgId != null) {
message.setServerMsgId(serverMsgId);
}
message.setTime(timestamp);
mXmppConnectionService.updateMessage(message, true);
} else {
Log.d(Config.LOGTAG, "unable to find original rtp session message for received propose");
}
}
}
break;
}
}
}
}
Element received = packet.findChild("received", "urn:xmpp:chat-markers:0");
@ -821,8 +907,14 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
if (query != null && id != null && packet.getTo() != null) {
query.removePendingReceiptRequest(new ReceiptRequest(packet.getTo(), id));
}
} else {
mXmppConnectionService.markMessage(account, from.asBareJid(), received.getAttribute("id"), Message.STATUS_SEND_RECEIVED);
} else if (id != null) {
if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX)) {
final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX.length());
mXmppConnectionService.getJingleConnectionManager()
.updateProposedSessionDiscovered(account, from, sessionId, JingleConnectionManager.DeviceDiscoveryState.DISCOVERED);
} else {
mXmppConnectionService.markMessage(account, from.asBareJid(), id, Message.STATUS_SEND_RECEIVED);
}
}
}
Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
@ -944,7 +1036,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
if (jid != null) {
Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, jid, true, false);
if (conversation.getMucOptions().online()) {
Log.d(Config.LOGTAG,account.getJid().asBareJid()+": received invite to "+jid+" but muc is considered to be online");
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received invite to " + jid + " but muc is considered to be online");
mXmppConnectionService.mucSelfPingAndRejoin(conversation);
} else {
conversation.getMucOptions().setPassword(password);

View File

@ -943,7 +943,7 @@ public class FileBackend {
final Bitmap rendered = renderPdfDocument(fileDescriptor, size, true);
drawOverlay(rendered, paintOverlayBlackPdf(rendered) ? R.drawable.open_pdf_black : R.drawable.open_pdf_white, 0.75f);
return rendered;
} catch (IOException e) {
} catch (final IOException | SecurityException e) {
Log.d(Config.LOGTAG, "unable to render PDF document preview", e);
final Bitmap placeholder = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
placeholder.eraseColor(0xff000000);
@ -1357,7 +1357,7 @@ public class FileBackend {
page.close();
pdfRenderer.close();
return scalePdfDimensions(new Dimensions(height, width));
} catch (IOException e) {
} catch (IOException | SecurityException e) {
Log.d(Config.LOGTAG, "unable to get dimensions for pdf document", e);
return new Dimensions(0, 0);
}

View File

@ -0,0 +1,634 @@
/*
* 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.support.annotation.Nullable;
import android.util.Log;
import org.webrtc.ThreadUtils;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
/**
* 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 final SpeakerPhonePreference speakerPhonePreference;
// Handles all tasks related to Bluetooth headset devices.
private final AppRTCBluetoothManager bluetoothManager;
@Nullable
private AudioManager audioManager;
@Nullable
private AudioManagerEvents audioManagerEvents;
private AudioManagerState amState;
private int savedAudioMode = AudioManager.MODE_INVALID;
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 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;
Log.d(Config.LOGTAG, "speaker phone preference: " + speakerPhonePreference);
this.speakerPhonePreference = speakerPhonePreference;
if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE) {
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);
}
/**
* 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")
// TODO(henrika): audioManager.requestAudioFocus() is deprecated.
public void start(AudioManagerEvents audioManagerEvents) {
Log.d(Config.LOGTAG, "start");
ThreadUtils.checkIsOnMainThread();
if (amState == AudioManagerState.RUNNING) {
Log.e(Config.LOGTAG, "AudioManager is already active");
return;
}
awaitMicrophoneLatch();
// TODO(henrika): perhaps call new method called preInitAudio() here if UNINITIALIZED.
Log.d(Config.LOGTAG, "AudioManager starts...");
this.audioManagerEvents = audioManagerEvents;
amState = AudioManagerState.RUNNING;
// Store current audio state so we can restore it when stop() is called.
savedAudioMode = audioManager.getMode();
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")
// TODO(henrika): audioManager.abandonAudioFocus() is deprecated.
public void stop() {
Log.d(Config.LOGTAG, "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(savedAudioMode);
// 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;
Log.d(Config.LOGTAG, "AudioManager stopped");
}
/**
* 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:
setSpeakerphoneOn(false);
break;
case WIRED_HEADSET:
setSpeakerphoneOn(false);
break;
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
}
/**
* 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,549 @@
/*
* 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.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.Handler;
import android.os.Looper;
import android.os.Process;
import android.support.annotation.Nullable;
import android.util.Log;
import java.util.List;
import java.util.Set;
import org.webrtc.ThreadUtils;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
/**
* 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();
Log.d(Config.LOGTAG, "start");
if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) {
Log.w(Config.LOGTAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission");
return;
}
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 (!audioManager.isBluetoothScoAvailableOffCall()) {
Log.e(Config.LOGTAG, "Bluetooth SCO audio is not available off call");
return;
}
logBluetoothAdapterInfo(bluetoothAdapter);
// 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);
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.
*/
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.
List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
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 hasPermission(Context context, String permission) {
return apprtcContext.checkPermission(permission, Process.myPid(), Process.myUid())
== PackageManager.PERMISSION_GRANTED;
}
/**
* Logs the state of the local Bluetooth adapter.
*/
@SuppressLint("HardwareIds")
protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
Log.d(Config.LOGTAG, "BluetoothAdapter: "
+ "enabled=" + localAdapter.isEnabled() + ", "
+ "state=" + stateToString(localAdapter.getState()) + ", "
+ "name=" + localAdapter.getName() + ", "
+ "address=" + localAdapter.getAddress());
// Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter.
Set<BluetoothDevice> pairedDevices = localAdapter.getBondedDevices();
if (!pairedDevices.isEmpty()) {
Log.d(Config.LOGTAG, "paired devices:");
for (BluetoothDevice device : pairedDevices) {
Log.d(Config.LOGTAG, " name=" + device.getName() + ", address=" + device.getAddress());
}
}
}
/**
* 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.
*/
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;
List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
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,170 @@
/*
* 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.support.annotation.Nullable;
import android.util.Log;
import org.webrtc.ThreadUtils;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
/**
* 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

@ -41,6 +41,7 @@ import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -55,12 +56,15 @@ import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.ui.ConversationsActivity;
import eu.siacs.conversations.ui.EditAccountActivity;
import eu.siacs.conversations.ui.RtpSessionActivity;
import eu.siacs.conversations.ui.TimePreference;
import eu.siacs.conversations.utils.AccountUtils;
import eu.siacs.conversations.utils.Compatibility;
import eu.siacs.conversations.utils.GeoHelper;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xmpp.XmppConnection;
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
public class NotificationService {
@ -68,11 +72,16 @@ public class NotificationService {
private static final int LED_COLOR = 0xff00ff00;
private static final int CALL_DAT = 120;
private static final long[] CALL_PATTERN = {0, 3 * CALL_DAT, CALL_DAT, CALL_DAT, 3 * CALL_DAT, CALL_DAT, CALL_DAT};
private static final String CONVERSATIONS_GROUP = "eu.siacs.conversations";
private static final int NOTIFICATION_ID_MULTIPLIER = 1024 * 1024;
private static final int NOTIFICATION_ID = 2 * NOTIFICATION_ID_MULTIPLIER;
static final int FOREGROUND_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 4;
private static final int NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 2;
private static final int ERROR_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 6;
private static final int INCOMING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 8;
public static final int ONGOING_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 10;
private final XmppConnectionService mXmppConnectionService;
private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
private final HashMap<Conversation, AtomicInteger> mBacklogMessageCounter = new HashMap<>();
@ -100,6 +109,14 @@ public class NotificationService {
return Pattern.compile("(?<=(^|\\s))" + Pattern.quote(nick) + "(?=\\s|$|\\p{Punct})");
}
private static boolean isImageMessage(Message message) {
return message.getType() != Message.TYPE_TEXT
&& message.getTransferable() == null
&& !message.isDeleted()
&& message.getEncryption() != Message.ENCRYPTION_PGP
&& message.getFileParams().height > 0;
}
@RequiresApi(api = Build.VERSION_CODES.O)
void initializeChannels() {
final Context c = mXmppConnectionService;
@ -112,6 +129,7 @@ public class NotificationService {
notificationManager.createNotificationChannelGroup(new NotificationChannelGroup("status", c.getString(R.string.notification_group_status_information)));
notificationManager.createNotificationChannelGroup(new NotificationChannelGroup("chats", c.getString(R.string.notification_group_messages)));
notificationManager.createNotificationChannelGroup(new NotificationChannelGroup("calls", c.getString(R.string.notification_group_calls)));
final NotificationChannel foregroundServiceChannel = new NotificationChannel("foreground",
c.getString(R.string.foreground_service_channel_name),
NotificationManager.IMPORTANCE_MIN);
@ -141,6 +159,30 @@ public class NotificationService {
exportChannel.setGroup("status");
notificationManager.createNotificationChannel(exportChannel);
final NotificationChannel incomingCallsChannel = new NotificationChannel("incoming_calls",
c.getString(R.string.incoming_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH);
incomingCallsChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE), new AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build());
incomingCallsChannel.setShowBadge(false);
incomingCallsChannel.setLightColor(LED_COLOR);
incomingCallsChannel.enableLights(true);
incomingCallsChannel.setGroup("calls");
incomingCallsChannel.setBypassDnd(true);
incomingCallsChannel.enableVibration(true);
incomingCallsChannel.setVibrationPattern(CALL_PATTERN);
notificationManager.createNotificationChannel(incomingCallsChannel);
final NotificationChannel ongoingCallsChannel = new NotificationChannel("ongoing_calls",
c.getString(R.string.ongoing_calls_channel_name),
NotificationManager.IMPORTANCE_LOW);
ongoingCallsChannel.setShowBadge(false);
ongoingCallsChannel.setGroup("calls");
notificationManager.createNotificationChannel(ongoingCallsChannel);
final NotificationChannel messagesChannel = new NotificationChannel("messages",
c.getString(R.string.messages_channel_name),
NotificationManager.IMPORTANCE_HIGH);
@ -188,7 +230,7 @@ public class NotificationService {
&& (!conversation.isWithStranger() || notificationsFromStrangers());
}
private boolean notificationsFromStrangers() {
public boolean notificationsFromStrangers() {
return mXmppConnectionService.getBooleanPreference("notifications_from_strangers", R.bool.notifications_from_strangers);
}
@ -300,6 +342,94 @@ public class NotificationService {
}
}
public void showIncomingCallNotification(final AbstractJingleConnection.Id id, final Set<Media> media) {
final Intent fullScreenIntent = new Intent(mXmppConnectionService, RtpSessionActivity.class);
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, id.account.getJid().asBareJid().toEscapedString());
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_WITH, id.with.toEscapedString());
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(mXmppConnectionService, "incoming_calls");
if (media.contains(Media.VIDEO)) {
builder.setSmallIcon(R.drawable.ic_videocam_white_24dp);
builder.setContentTitle(mXmppConnectionService.getString(R.string.rtp_state_incoming_video_call));
} else {
builder.setSmallIcon(R.drawable.ic_call_white_24dp);
builder.setContentTitle(mXmppConnectionService.getString(R.string.rtp_state_incoming_call));
}
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(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_white_48dp,
mXmppConnectionService.getString(R.string.dismiss_call),
createCallAction(id.sessionId, XmppConnectionService.ACTION_DISMISS_CALL, 102))
.build());
builder.addAction(new NotificationCompat.Action.Builder(
R.drawable.ic_call_white_24dp,
mXmppConnectionService.getString(R.string.answer_call),
createPendingRtpSession(id, RtpSessionActivity.ACTION_ACCEPT_CALL, 103))
.build());
modifyIncomingCall(builder);
final Notification notification = builder.build();
notification.flags = notification.flags | Notification.FLAG_INSISTENT;
notify(INCOMING_CALL_NOTIFICATION_ID, notification);
}
public Notification getOngoingCallNotification(final AbstractJingleConnection.Id id, final Set<Media> media) {
final NotificationCompat.Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "ongoing_calls");
if (media.contains(Media.VIDEO)) {
builder.setSmallIcon(R.drawable.ic_videocam_white_24dp);
builder.setContentTitle(mXmppConnectionService.getString(R.string.ongoing_video_call));
} else {
builder.setSmallIcon(R.drawable.ic_call_white_24dp);
builder.setContentTitle(mXmppConnectionService.getString(R.string.ongoing_call));
}
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(id, Intent.ACTION_VIEW, 101));
builder.setOngoing(true);
builder.addAction(new NotificationCompat.Action.Builder(
R.drawable.ic_call_end_white_48dp,
mXmppConnectionService.getString(R.string.hang_up),
createCallAction(id.sessionId, XmppConnectionService.ACTION_END_CALL, 104))
.build());
return builder.build();
}
private PendingIntent createPendingRtpSession(final AbstractJingleConnection.Id id, final String action, final int requestCode) {
final Intent fullScreenIntent = new Intent(mXmppConnectionService, RtpSessionActivity.class);
fullScreenIntent.setAction(action);
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, id.account.getJid().asBareJid().toEscapedString());
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_WITH, id.with.toEscapedString());
fullScreenIntent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, id.sessionId);
return PendingIntent.getActivity(mXmppConnectionService, requestCode, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);
}
public void cancelIncomingCallNotification() {
cancel(INCOMING_CALL_NOTIFICATION_ID);
}
public void cancelOngoingCallNotification() {
cancel(ONGOING_CALL_NOTIFICATION_ID);
}
private void pushNow(final Message message) {
mXmppConnectionService.updateUnreadCountBadge();
if (!notify(message)) {
@ -456,6 +586,25 @@ public class NotificationService {
}
}
private void modifyIncomingCall(Builder mBuilder) {
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mXmppConnectionService);
final Resources resources = mXmppConnectionService.getResources();
final String ringtone = preferences.getString("call_ringtone", resources.getString(R.string.incoming_call_ringtone));
mBuilder.setVibrate(CALL_PATTERN);
final Uri uri = Uri.parse(ringtone);
try {
mBuilder.setSound(fixRingtoneUri(uri));
} catch (SecurityException e) {
Log.d(Config.LOGTAG, "unable to use custom notification sound " + uri.toString());
}
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
mBuilder.setCategory(Notification.CATEGORY_MESSAGE);
}
mBuilder.setPriority(NotificationCompat.PRIORITY_HIGH);
setNotificationColor(mBuilder);
mBuilder.setLights(LED_COLOR, 2000, 3000);
}
private Uri fixRingtoneUri(Uri uri) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && "file".equals(uri.getScheme())) {
return FileBackend.getUriForFile(mXmppConnectionService, new File(uri.getPath()));
@ -467,7 +616,7 @@ public class NotificationService {
private Builder buildMultipleConversation(final boolean notify, final boolean quietHours) {
final Builder mBuilder = new NotificationCompat.Builder(mXmppConnectionService, quietHours ? "quiet_hours" : (notify ? "messages" : "silent_messages"));
final NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle();
style.setBigContentTitle(mXmppConnectionService.getString(R.string.x_unread_conversations,notifications.size()));
style.setBigContentTitle(mXmppConnectionService.getString(R.string.x_unread_conversations, notifications.size()));
final StringBuilder names = new StringBuilder();
Conversation conversation = null;
for (final ArrayList<Message> messages : notifications.values()) {
@ -652,7 +801,7 @@ public class NotificationService {
return builder.build();
}
private void modifyForTextOnly(final Builder builder, final ArrayList<Message> messages) {
private void modifyForTextOnly(final Builder builder, final ArrayList<Message> messages) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final Conversation conversation = (Conversation) messages.get(0).getConversation();
final Person.Builder meBuilder = new Person.Builder().setName(mXmppConnectionService.getString(R.string.me));
@ -668,7 +817,7 @@ public class NotificationService {
for (Message message : messages) {
final Person sender = message.getStatus() == Message.STATUS_RECEIVED ? getPerson(message) : null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isImageMessage(message)) {
final Uri dataUri = FileBackend.getMediaUri(mXmppConnectionService,mXmppConnectionService.getFileBackend().getFile(message));
final Uri dataUri = FileBackend.getMediaUri(mXmppConnectionService, mXmppConnectionService.getFileBackend().getFile(message));
NotificationCompat.MessagingStyle.Message imageMessage = new NotificationCompat.MessagingStyle.Message(UIHelper.getMessagePreview(mXmppConnectionService, message).first, message.getTimeSent(), sender);
if (dataUri != null) {
imageMessage.setData(message.getMimeType(), dataUri);
@ -683,7 +832,7 @@ public class NotificationService {
} else {
if (messages.get(0).getConversation().getMode() == Conversation.MODE_SINGLE) {
builder.setStyle(new NotificationCompat.BigTextStyle().bigText(getMergedBodies(messages)));
final CharSequence preview = UIHelper.getMessagePreview(mXmppConnectionService, messages.get(messages.size()-1)).first;
final CharSequence preview = UIHelper.getMessagePreview(mXmppConnectionService, messages.get(messages.size() - 1)).first;
builder.setContentText(preview);
builder.setTicker(preview);
builder.setNumber(messages.size());
@ -726,14 +875,6 @@ public class NotificationService {
return image;
}
private static boolean isImageMessage(Message message) {
return message.getType() != Message.TYPE_TEXT
&& message.getTransferable() == null
&& !message.isDeleted()
&& message.getEncryption() != Message.ENCRYPTION_PGP
&& message.getFileParams().height > 0;
}
private Message getFirstDownloadableMessage(final Iterable<Message> messages) {
for (final Message message : messages) {
if (message.getTransferable() != null || (message.getType() == Message.TYPE_TEXT && message.treatAsDownloadable())) {
@ -834,6 +975,14 @@ public class NotificationService {
return PendingIntent.getService(mXmppConnectionService, generateRequestCode(conversation, 16), intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
private PendingIntent createCallAction(String sessionId, final String action, int requestCode) {
final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
intent.setAction(action);
intent.setPackage(mXmppConnectionService.getPackageName());
intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId);
return PendingIntent.getService(mXmppConnectionService, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
private PendingIntent createSnoozeIntent(Conversation conversation) {
final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
intent.setAction(XmppConnectionService.ACTION_SNOOZE);
@ -1003,10 +1152,6 @@ public class NotificationService {
notify(FOREGROUND_NOTIFICATION_ID, notification);
}
void dismissForcedForegroundNotification() {
cancel(FOREGROUND_NOTIFICATION_ID);
}
private void notify(String tag, int id, Notification notification) {
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(mXmppConnectionService);
try {
@ -1025,7 +1170,7 @@ public class NotificationService {
}
}
private void cancel(int id) {
public void cancel(int id) {
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(mXmppConnectionService);
try {
notificationManager.cancel(id);

View File

@ -42,6 +42,7 @@ import android.util.Log;
import android.util.LruCache;
import android.util.Pair;
import com.google.common.base.Objects;
import com.google.common.base.Strings;
import org.conscrypt.Conscrypt;
@ -71,6 +72,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
@ -107,6 +109,7 @@ import eu.siacs.conversations.parser.PresenceParser;
import eu.siacs.conversations.persistance.DatabaseBackend;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.ui.ChooseAccountForProfilePictureActivity;
import eu.siacs.conversations.ui.RtpSessionActivity;
import eu.siacs.conversations.ui.SettingsActivity;
import eu.siacs.conversations.ui.UiCallback;
import eu.siacs.conversations.ui.interfaces.OnAvatarPublication;
@ -141,9 +144,10 @@ import eu.siacs.conversations.xmpp.Patches;
import eu.siacs.conversations.xmpp.XmppConnection;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.forms.Data;
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
import eu.siacs.conversations.xmpp.mam.MamReference;
import eu.siacs.conversations.xmpp.pep.Avatar;
import eu.siacs.conversations.xmpp.pep.PublishOptions;
@ -164,6 +168,8 @@ public class XmppConnectionService extends Service {
public static final String ACTION_IDLE_PING = "idle_ping";
public static final String ACTION_FCM_TOKEN_REFRESH = "fcm_token_refresh";
public static final String ACTION_FCM_MESSAGE_RECEIVED = "fcm_message_received";
public static final String ACTION_DISMISS_CALL = "dismiss_call";
public static final String ACTION_END_CALL = "end_call";
private static final String ACTION_POST_CONNECTIVITY_CHANGE = "eu.siacs.conversations.POST_CONNECTIVITY_CHANGE";
private static final String SETTING_LAST_ACTIVITY_TS = "last_activity_timestamp";
@ -205,6 +211,7 @@ public class XmppConnectionService extends Service {
private AtomicBoolean mInitialAddressbookSyncCompleted = new AtomicBoolean(false);
private AtomicBoolean mForceForegroundService = new AtomicBoolean(false);
private AtomicBoolean mForceDuringOnCreate = new AtomicBoolean(false);
private AtomicReference<OngoingCall> ongoingCall = new AtomicReference<>();
private OnMessagePacketReceived mMessageParser = new MessageParser(this);
private OnPresencePacketReceived mPresenceParser = new PresenceParser(this);
private IqParser mIqParser = new IqParser(this);
@ -221,15 +228,7 @@ public class XmppConnectionService extends Service {
};
private PresenceGenerator mPresenceGenerator = new PresenceGenerator(this);
private List<Account> accounts;
private JingleConnectionManager mJingleConnectionManager = new JingleConnectionManager(
this);
private final OnJinglePacketReceived jingleListener = new OnJinglePacketReceived() {
@Override
public void onJinglePacketReceived(Account account, JinglePacket packet) {
mJingleConnectionManager.deliverPacket(account, packet);
}
};
private JingleConnectionManager mJingleConnectionManager = new JingleConnectionManager(this);
private HttpConnectionManager mHttpConnectionManager = new HttpConnectionManager(this);
private AvatarService mAvatarService = new AvatarService(this);
private MessageArchiveService mMessageArchiveService = new MessageArchiveService(this);
@ -275,6 +274,7 @@ public class XmppConnectionService extends Service {
private final Set<OnUpdateBlocklist> mOnUpdateBlocklist = Collections.newSetFromMap(new WeakHashMap<OnUpdateBlocklist, Boolean>());
private final Set<OnMucRosterUpdate> mOnMucRosterUpdate = Collections.newSetFromMap(new WeakHashMap<OnMucRosterUpdate, Boolean>());
private final Set<OnKeyStatusUpdated> mOnKeyStatusUpdated = Collections.newSetFromMap(new WeakHashMap<OnKeyStatusUpdated, Boolean>());
private final Set<OnJingleRtpConnectionUpdate> onJingleRtpConnectionUpdate = Collections.newSetFromMap(new WeakHashMap<OnJingleRtpConnectionUpdate, Boolean>());
private final Object LISTENER_LOCK = new Object();
@ -314,7 +314,7 @@ public class XmppConnectionService extends Service {
synchronized (account.inProgressConferencePings) {
account.inProgressConferencePings.clear();
}
mJingleConnectionManager.cancelInTransmission();
mJingleConnectionManager.notifyRebound();
mQuickConversationsService.considerSyncBackground(false);
fetchRosterFromServer(account);
@ -644,6 +644,18 @@ public class XmppConnectionService extends Service {
}
});
break;
case ACTION_DISMISS_CALL: {
final String sessionId = intent.getStringExtra(RtpSessionActivity.EXTRA_SESSION_ID);
Log.d(Config.LOGTAG, "received intent to dismiss call with session id " + sessionId);
mJingleConnectionManager.rejectRtpSession(sessionId);
}
break;
case ACTION_END_CALL: {
final String sessionId = intent.getStringExtra(RtpSessionActivity.EXTRA_SESSION_ID);
Log.d(Config.LOGTAG, "received intent to end call with session id " + sessionId);
mJingleConnectionManager.endRtpSession(sessionId);
}
break;
case ACTION_DISMISS_ERROR_NOTIFICATIONS:
dismissErrorNotifications();
break;
@ -1218,11 +1230,30 @@ public class XmppConnectionService extends Service {
toggleForegroundService(false);
}
public void setOngoingCall(AbstractJingleConnection.Id id, Set<Media> media) {
ongoingCall.set(new OngoingCall(id, media));
toggleForegroundService(false);
}
public void removeOngoingCall() {
ongoingCall.set(null);
toggleForegroundService(false);
}
private void toggleForegroundService(boolean force) {
final boolean status;
if (force || mForceDuringOnCreate.get() || mForceForegroundService.get() || (Compatibility.keepForegroundService(this) && hasEnabledAccounts())) {
final Notification notification = this.mNotificationService.createForegroundNotification();
startForeground(NotificationService.FOREGROUND_NOTIFICATION_ID, notification);
final OngoingCall ongoing = ongoingCall.get();
if (force || mForceDuringOnCreate.get() || mForceForegroundService.get() || ongoing != null || (Compatibility.keepForegroundService(this) && hasEnabledAccounts())) {
final Notification notification;
if (ongoing != null) {
notification = this.mNotificationService.getOngoingCallNotification(ongoing.id, ongoing.media);
startForeground(NotificationService.ONGOING_CALL_NOTIFICATION_ID, notification);
mNotificationService.cancel(NotificationService.FOREGROUND_NOTIFICATION_ID);
} else {
notification = this.mNotificationService.createForegroundNotification();
startForeground(NotificationService.FOREGROUND_NOTIFICATION_ID, notification);
}
if (!mForceForegroundService.get()) {
mNotificationService.notify(NotificationService.FOREGROUND_NOTIFICATION_ID, notification);
}
@ -1232,19 +1263,22 @@ public class XmppConnectionService extends Service {
status = false;
}
if (!mForceForegroundService.get()) {
mNotificationService.dismissForcedForegroundNotification(); //if the channel was changed the previous call might fail
mNotificationService.cancel(NotificationService.FOREGROUND_NOTIFICATION_ID);
}
if (ongoing == null) {
mNotificationService.cancel(NotificationService.ONGOING_CALL_NOTIFICATION_ID);
}
Log.d(Config.LOGTAG, "ForegroundService: " + (status ? "on" : "off"));
}
public boolean foregroundNotificationNeedsUpdatingWhenErrorStateChanges() {
return !mForceForegroundService.get() && Compatibility.keepForegroundService(this) && hasEnabledAccounts();
return !mForceForegroundService.get() && ongoingCall.get() == null && Compatibility.keepForegroundService(this) && hasEnabledAccounts();
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if ((Compatibility.keepForegroundService(this) && hasEnabledAccounts()) || mForceForegroundService.get()) {
if ((Compatibility.keepForegroundService(this) && hasEnabledAccounts()) || mForceForegroundService.get() || ongoingCall.get() != null) {
Log.d(Config.LOGTAG, "ignoring onTaskRemoved because foreground service is activated");
} else {
this.logoutAndSave(false);
@ -1327,7 +1361,7 @@ public class XmppConnectionService extends Service {
connection.setOnStatusChangedListener(this.statusListener);
connection.setOnPresencePacketReceivedListener(this.mPresenceParser);
connection.setOnUnregisteredIqPacketReceivedListener(this.mIqParser);
connection.setOnJinglePacketReceivedListener(this.jingleListener);
connection.setOnJinglePacketReceivedListener(((a, jp) -> mJingleConnectionManager.deliverPacket(a, jp)));
connection.setOnBindListener(this.mOnBindListener);
connection.setOnMessageAcknowledgeListener(this.mOnMessageAcknowledgedListener);
connection.addOnAdvancedStreamFeaturesAvailableListener(this.mMessageArchiveService);
@ -1353,7 +1387,7 @@ public class XmppConnectionService extends Service {
|| message.getConversation().getMode() == Conversation.MODE_MULTI) {
mHttpConnectionManager.createNewUploadConnection(message, delay);
} else {
mJingleConnectionManager.createNewConnection(message);
mJingleConnectionManager.startJingleFileTransfer(message);
}
}
@ -2475,6 +2509,30 @@ public class XmppConnectionService extends Service {
}
}
public void setOnRtpConnectionUpdateListener(final OnJingleRtpConnectionUpdate listener) {
final boolean remainingListeners;
synchronized (LISTENER_LOCK) {
remainingListeners = checkListeners();
if (!this.onJingleRtpConnectionUpdate.add(listener)) {
Log.w(Config.LOGTAG, listener.getClass().getName() + " is already registered as OnJingleRtpConnectionUpdate");
}
}
if (remainingListeners) {
switchToForeground();
}
}
public void removeRtpConnectionUpdateListener(final OnJingleRtpConnectionUpdate listener) {
final boolean remainingListeners;
synchronized (LISTENER_LOCK) {
this.onJingleRtpConnectionUpdate.remove(listener);
remainingListeners = checkListeners();
}
if (remainingListeners) {
switchToBackground();
}
}
public void setOnMucRosterUpdateListener(OnMucRosterUpdate listener) {
final boolean remainingListeners;
synchronized (LISTENER_LOCK) {
@ -2507,6 +2565,7 @@ public class XmppConnectionService extends Service {
&& this.mOnMucRosterUpdate.size() == 0
&& this.mOnUpdateBlocklist.size() == 0
&& this.mOnShowErrorToasts.size() == 0
&& this.onJingleRtpConnectionUpdate.size() == 0
&& this.mOnKeyStatusUpdated.size() == 0);
}
@ -3951,6 +4010,18 @@ public class XmppConnectionService extends Service {
}
}
public void notifyJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state) {
for (OnJingleRtpConnectionUpdate listener : threadSafeList(this.onJingleRtpConnectionUpdate)) {
listener.onJingleRtpConnectionUpdate(account, with, sessionId, state);
}
}
public void notifyJingleRtpConnectionUpdate(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
for (OnJingleRtpConnectionUpdate listener : threadSafeList(this.onJingleRtpConnectionUpdate)) {
listener.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
}
}
public void updateAccountUi() {
for (OnAccountUpdate listener : threadSafeList(this.mOnAccountUpdates)) {
listener.onAccountUpdate();
@ -3994,9 +4065,9 @@ public class XmppConnectionService extends Service {
}
}
public Account findAccountByJid(final Jid accountJid) {
for (Account account : this.accounts) {
if (account.getJid().asBareJid().equals(accountJid.asBareJid())) {
public Account findAccountByJid(final Jid jid) {
for (final Account account : this.accounts) {
if (account.getJid().asBareJid().equals(jid.asBareJid())) {
return account;
}
}
@ -4628,6 +4699,12 @@ public class XmppConnectionService extends Service {
void onConversationUpdate();
}
public interface OnJingleRtpConnectionUpdate {
void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state);
void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
}
public interface OnAccountUpdate {
void onAccountUpdate();
}
@ -4677,4 +4754,27 @@ public class XmppConnectionService extends Service {
onStartCommand(intent, 0, 0);
}
}
public static class OngoingCall {
private final AbstractJingleConnection.Id id;
private final Set<Media> media;
public OngoingCall(AbstractJingleConnection.Id id, Set<Media> media) {
this.id = id;
this.media = media;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OngoingCall that = (OngoingCall) o;
return Objects.equal(id, that.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}
}

View File

@ -18,6 +18,7 @@ import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import java.util.Collections;
import java.util.List;
@ -224,10 +225,12 @@ public class ChannelDiscoveryActivity extends XmppActivity implements MenuItem.O
@Override
public void onChannelSearchResult(final Room result) {
List<String> accounts = AccountUtils.getEnabledAccounts(xmppConnectionService);
final List<String> accounts = AccountUtils.getEnabledAccounts(xmppConnectionService);
if (accounts.size() == 1) {
joinChannelSearchResult(accounts.get(0), result);
} else if (accounts.size() > 0) {
} else if (accounts.size() == 0) {
Toast.makeText(this, R.string.please_enable_an_account, Toast.LENGTH_LONG).show();
} else {
final AtomicReference<String> account = new AtomicReference<>(accounts.get(0));
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(R.string.choose_account);

View File

@ -207,7 +207,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
});
binding.addContactButton.setOnClickListener(v -> showAddToRosterDialog(contact));
mMediaAdapter = new MediaAdapter(this,R.dimen.media_size);
mMediaAdapter = new MediaAdapter(this, R.dimen.media_size);
this.binding.media.setAdapter(mMediaAdapter);
GridManager.setupLayoutManager(this, this.binding.media, R.dimen.media_size);
}
@ -416,7 +416,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
account = contact.getAccount().getJid().asBareJid().toString();
}
binding.detailsAccount.setText(getString(R.string.using_account, account));
AvatarWorkerTask.loadAvatar(contact,binding.detailsContactBadge,R.dimen.avatar_on_details_screen_size);
AvatarWorkerTask.loadAvatar(contact, binding.detailsContactBadge, R.dimen.avatar_on_details_screen_size);
binding.detailsContactBadge.setOnClickListener(this.onBadgeClick);
binding.detailsContactKeys.removeAllViews();
@ -426,7 +426,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
if (Config.supportOmemo() && axolotlService != null) {
final Collection<XmppAxolotlSession> sessions = axolotlService.findSessionsForContact(contact);
boolean anyActive = false;
for(XmppAxolotlSession session : sessions) {
for (XmppAxolotlSession session : sessions) {
anyActive = session.getTrust().isActive();
if (anyActive) {
break;
@ -434,7 +434,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
}
boolean skippedInactive = false;
boolean showsInactive = false;
for (final XmppAxolotlSession session :sessions) {
for (final XmppAxolotlSession session : sessions) {
final FingerprintStatus trust = session.getTrust();
hasKeys |= !trust.isCompromised();
if (!trust.isActive() && anyActive) {
@ -537,7 +537,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp
public void onMediaLoaded(List<Attachment> attachments) {
runOnUiThread(() -> {
int limit = GridManager.getCurrentColumnCount(binding.media);
mMediaAdapter.setAttachments(attachments.subList(0, Math.min(limit,attachments.size())));
mMediaAdapter.setAttachments(attachments.subList(0, Math.min(limit, attachments.size())));
binding.mediaWrapper.setVisibility(attachments.size() > 0 ? View.VISIBLE : View.GONE);
});

View File

@ -82,6 +82,7 @@ import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.entities.TransferablePlaceholder;
import eu.siacs.conversations.http.HttpDownloadConnection;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.AppRTCAudioManager;
import eu.siacs.conversations.services.MessageArchiveService;
import eu.siacs.conversations.services.QuickConversationsService;
import eu.siacs.conversations.services.XmppConnectionService;
@ -109,13 +110,15 @@ import eu.siacs.conversations.utils.GeoHelper;
import eu.siacs.conversations.utils.MessageUtils;
import eu.siacs.conversations.utils.NickValidityChecker;
import eu.siacs.conversations.utils.Patterns;
import eu.siacs.conversations.utils.PermissionUtils;
import eu.siacs.conversations.utils.QuickLoader;
import eu.siacs.conversations.utils.StylingHelper;
import eu.siacs.conversations.utils.TimeframeUtils;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xmpp.XmppConnection;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.jingle.JingleConnection;
import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
import eu.siacs.conversations.xmpp.jingle.RtpCapability;
import rocks.xmpp.addr.Jid;
import static eu.siacs.conversations.ui.XmppActivity.EXTRA_ACCOUNT;
@ -137,6 +140,8 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
public static final int REQUEST_START_DOWNLOAD = 0x0210;
public static final int REQUEST_ADD_EDITOR_CONTENT = 0x0211;
public static final int REQUEST_COMMIT_ATTACHMENTS = 0x0212;
public static final int REQUEST_START_AUDIO_CALL = 0x213;
public static final int REQUEST_START_VIDEO_CALL = 0x214;
public static final int ATTACHMENT_CHOICE_CHOOSE_IMAGE = 0x0301;
public static final int ATTACHMENT_CHOICE_TAKE_PHOTO = 0x0302;
public static final int ATTACHMENT_CHOICE_CHOOSE_FILE = 0x0303;
@ -198,7 +203,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
private OnClickListener acceptJoin = new OnClickListener() {
@Override
public void onClick(View v) {
conversation.setAttribute("accept_non_anonymous",true);
conversation.setAttribute("accept_non_anonymous", true);
activity.xmppConnectionService.updateConversation(conversation);
activity.xmppConnectionService.joinMuc(conversation);
}
@ -950,6 +955,8 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
final MenuItem menuInviteContact = menu.findItem(R.id.action_invite);
final MenuItem menuMute = menu.findItem(R.id.action_mute);
final MenuItem menuUnmute = menu.findItem(R.id.action_unmute);
final MenuItem menuCall = menu.findItem(R.id.action_call);
final MenuItem menuVideoCall = menu.findItem(R.id.action_video_call);
if (conversation != null) {
@ -957,7 +964,11 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
menuContactDetails.setVisible(false);
menuInviteContact.setVisible(conversation.getMucOptions().canInvite());
menuMucDetails.setTitle(conversation.getMucOptions().isPrivateAndNonAnonymous() ? R.string.action_muc_details : R.string.channel_details);
menuCall.setVisible(false);
} else {
final RtpCapability.Capability rtpCapability = RtpCapability.check(conversation.getContact());
menuCall.setVisible(rtpCapability != RtpCapability.Capability.NONE);
menuVideoCall.setVisible(rtpCapability == RtpCapability.Capability.VIDEO);
menuContactDetails.setVisible(!this.conversation.withSelf());
menuMucDetails.setVisible(false);
final XmppConnectionService service = activity.xmppConnectionService;
@ -1038,7 +1049,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
while (relevantForCorrection.mergeable(relevantForCorrection.next())) {
relevantForCorrection = relevantForCorrection.next();
}
if (m.getType() != Message.TYPE_STATUS) {
if (m.getType() != Message.TYPE_STATUS && m.getType() != Message.TYPE_RTP_SESSION) {
if (m.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || m.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
return;
@ -1051,7 +1062,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
final boolean deleted = m.isDeleted();
final boolean encrypted = m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED
|| m.getEncryption() == Message.ENCRYPTION_PGP;
final boolean receiving = m.getStatus() == Message.STATUS_RECEIVED && (t instanceof JingleConnection || t instanceof HttpDownloadConnection);
final boolean receiving = m.getStatus() == Message.STATUS_RECEIVED && (t instanceof JingleFileTransferConnection || t instanceof HttpDownloadConnection);
activity.getMenuInflater().inflate(R.menu.message_context, menu);
menu.setHeaderTitle(R.string.message_options);
MenuItem openWith = menu.findItem(R.id.open_with);
@ -1123,7 +1134,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
showErrorMessage.setVisible(true);
}
final String mime = m.isFileOrImage() ? m.getMimeType() : null;
if ((m.isGeoUri() && GeoHelper.openInOsmAnd(getActivity(),m)) || (mime != null && mime.startsWith("audio/"))) {
if ((m.isGeoUri() && GeoHelper.openInOsmAnd(getActivity(), m)) || (mime != null && mime.startsWith("audio/"))) {
openWith.setVisible(true);
}
}
@ -1228,12 +1239,54 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
BlockContactDialog.show((XmppActivity) activity, conversation);
}
break;
case R.id.action_audio_call:
checkPermissionAndTriggerAudioCall();
break;
case R.id.action_video_call:
checkPermissionAndTriggerVideoCall();
break;
default:
break;
}
return super.onOptionsItemSelected(item);
}
private void checkPermissionAndTriggerAudioCall() {
if (activity.mUseTor || conversation.getAccount().isOnion()) {
Toast.makeText(activity, R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show();
return;
}
if (hasPermissions(REQUEST_START_AUDIO_CALL, Manifest.permission.RECORD_AUDIO)) {
triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
}
}
private void checkPermissionAndTriggerVideoCall() {
if (activity.mUseTor || conversation.getAccount().isOnion()) {
Toast.makeText(activity, R.string.disable_tor_to_make_call, Toast.LENGTH_SHORT).show();
return;
}
if (hasPermissions(REQUEST_START_VIDEO_CALL, Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)) {
triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
}
}
private void triggerRtpSession(final String action) {
if (activity.xmppConnectionService.getJingleConnectionManager().isBusy()) {
Toast.makeText(getActivity(), R.string.only_one_call_at_a_time, Toast.LENGTH_LONG).show();
return;
}
final Contact contact = conversation.getContact();
final Intent intent = new Intent(activity, RtpSessionActivity.class);
intent.setAction(action);
intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, contact.getAccount().getJid().toEscapedString());
intent.putExtra(RtpSessionActivity.EXTRA_WITH, contact.getJid().asBareJid().toEscapedString());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
}
private void handleAttachmentSelection(MenuItem item) {
switch (item.getItemId()) {
case R.id.attach_choose_picture:
@ -1367,7 +1420,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) {
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (grantResults.length > 0) {
if (allGranted(grantResults)) {
switch (requestCode) {
@ -1384,6 +1437,12 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
case REQUEST_COMMIT_ATTACHMENTS:
commitAttachments();
break;
case REQUEST_START_AUDIO_CALL:
triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
break;
case REQUEST_START_VIDEO_CALL:
triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
break;
default:
attachFile(requestCode);
break;
@ -1427,7 +1486,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
} else if (message.treatAsDownloadable() || message.hasFileOnRemoteHost() || MessageUtils.unInitiatedButKnownSize(message)) {
createNewConnection(message);
} else {
Log.d(Config.LOGTAG,message.getConversation().getAccount()+": unable to start downloadable");
Log.d(Config.LOGTAG, message.getConversation().getAccount() + ": unable to start downloadable");
}
}
@ -1617,7 +1676,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
private void openWith(final Message message) {
if (message.isGeoUri()) {
GeoHelper.view(getActivity(),message);
GeoHelper.view(getActivity(), message);
} else {
final DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
ViewUtil.view(activity, file);
@ -1637,8 +1696,8 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
}
builder.setMessage(displayError);
builder.setNegativeButton(R.string.copy_to_clipboard, (dialog, which) -> {
activity.copyTextToClipboard(displayError,R.string.error_message);
Toast.makeText(activity,R.string.error_message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
activity.copyTextToClipboard(displayError, R.string.error_message);
Toast.makeText(activity, R.string.error_message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
});
builder.setPositiveButton(R.string.confirm, null);
builder.create().show();
@ -1936,7 +1995,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
this.binding.textinput.append(this.conversation.getNextMessage());
}
this.binding.textinput.setKeyboardListener(this);
messageListAdapter.updatePreferences();
refresh(false);
this.conversation.messagesLoaded.set(true);
Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + Boolean.toString(scrolledToBottomAndNoPending));
@ -2740,10 +2798,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
Log.e(Config.LOGTAG, "cleared pending photo uri");
}
if (pendingConversationsUuid.clear()) {
Log.e(Config.LOGTAG,"cleared pending conversations uuid");
Log.e(Config.LOGTAG, "cleared pending conversations uuid");
}
if (pendingMediaPreviews.clear()) {
Log.e(Config.LOGTAG,"cleared pending media previews");
Log.e(Config.LOGTAG, "cleared pending media previews");
}
}
@ -2761,7 +2819,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
}
final PopupMenu popupMenu = new PopupMenu(getActivity(), v);
final Contact contact = message.getContact();
if (message.getStatus() <= Message.STATUS_RECEIVED && (contact == null || !contact.isSelf())) {
if (message.getStatus() <= Message.STATUS_RECEIVED && (contact == null || !contact.isSelf())) {
if (message.getConversation().getMode() == Conversation.MODE_MULTI) {
final Jid cp = message.getCounterpart();
if (cp == null || cp.isBareJid()) {
@ -2844,4 +2902,4 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
}
activity.switchToAccount(message.getConversation().getAccount(), fingerprint);
}
}
}

View File

@ -383,7 +383,6 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
if (isCameraFeatureAvailable()) {
Fragment fragment = getFragmentManager().findFragmentById(R.id.main_fragment);
boolean visible = getResources().getBoolean(R.bool.show_qr_code_scan)
&& fragment != null
&& fragment instanceof ConversationsOverviewFragment;
qrCodeScanMenuItem.setVisible(visible);
} else {

View File

@ -3,7 +3,6 @@ package eu.siacs.conversations.ui;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.SharedPreferences;
@ -63,7 +62,6 @@ import eu.siacs.conversations.ui.util.AvatarWorkerTask;
import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
import eu.siacs.conversations.ui.util.PendingItem;
import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
import eu.siacs.conversations.ui.util.StyledAttributes;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.Resolver;
import eu.siacs.conversations.utils.SignupUtils;
@ -420,7 +418,8 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
}
if (xmppConnectionService.getAccounts().size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) {
final List<Account> accounts = xmppConnectionService == null ? null : xmppConnectionService.getAccounts();
if (accounts != null && accounts.size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) {
Intent intent = SignupUtils.getSignUpIntent(this, mForceRegister != null && mForceRegister);
StartConversationActivity.addInviteUri(intent, getIntent());
startActivity(intent);
@ -1059,6 +1058,11 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
} else {
this.binding.serverInfoSm.setText(R.string.server_info_unavailable);
}
if (features.externalServiceDiscovery()) {
this.binding.serverInfoExternalService.setText(R.string.server_info_available);
} else {
this.binding.serverInfoExternalService.setText(R.string.server_info_unavailable);
}
if (features.pep()) {
AxolotlService axolotlService = this.mAccount.getAxolotlService();
if (axolotlService != null && axolotlService.isPepBroken()) {

View File

@ -81,8 +81,8 @@ public abstract class OmemoActivity extends XmppActivity {
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent intent) {
super.onActivityResult(requestCode, requestCode, intent);
public void onActivityResult(final int requestCode, final int resultCode, final Intent intent) {
super.onActivityResult(requestCode, resultCode, intent);
if (requestCode == ScanActivity.REQUEST_SCAN_QR_CODE && resultCode == RESULT_OK) {
String result = intent.getStringExtra(ScanActivity.INTENT_EXTRA_RESULT);
XmppUri uri = new XmppUri(result == null ? "" : result);

View File

@ -0,0 +1,840 @@
package eu.siacs.conversations.ui;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.databinding.DataBindingUtil;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.os.SystemClock;
import android.support.annotation.NonNull;
import android.support.annotation.RequiresApi;
import android.support.annotation.StringRes;
import android.util.Log;
import android.util.Rational;
import android.view.View;
import android.view.WindowManager;
import android.widget.Toast;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoTrack;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.ActivityRtpSessionBinding;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.services.AppRTCAudioManager;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.util.AvatarWorkerTask;
import eu.siacs.conversations.utils.PermissionUtils;
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.RtpEndUserState;
import rocks.xmpp.addr.Jid;
import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
import static java.util.Arrays.asList;
public class RtpSessionActivity extends XmppActivity implements XmppConnectionService.OnJingleRtpConnectionUpdate {
public static final String EXTRA_WITH = "with";
public static final String EXTRA_SESSION_ID = "session_id";
public static final String EXTRA_LAST_REPORTED_STATE = "last_reported_state";
public static final String EXTRA_LAST_ACTION = "last_action";
public static final String ACTION_ACCEPT_CALL = "action_accept_call";
public static final String ACTION_MAKE_VOICE_CALL = "action_make_voice_call";
public static final String ACTION_MAKE_VIDEO_CALL = "action_make_video_call";
private static final List<RtpEndUserState> END_CARD = Arrays.asList(
RtpEndUserState.APPLICATION_ERROR,
RtpEndUserState.DECLINED_OR_BUSY,
RtpEndUserState.CONNECTIVITY_ERROR
);
private static final String PROXIMITY_WAKE_LOCK_TAG = "conversations:in-rtp-session";
private static final int REQUEST_ACCEPT_CALL = 0x1111;
private WeakReference<JingleRtpConnection> rtpConnectionReference;
private ActivityRtpSessionBinding binding;
private PowerManager.WakeLock mProximityWakeLock;
private static Set<Media> actionToMedia(final String action) {
if (ACTION_MAKE_VIDEO_CALL.equals(action)) {
return ImmutableSet.of(Media.AUDIO, Media.VIDEO);
} else {
return ImmutableSet.of(Media.AUDIO);
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
Log.d(Config.LOGTAG, this.getClass().getName() + ".onCreate()");
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
this.binding = DataBindingUtil.setContentView(this, R.layout.activity_rtp_session);
setSupportActionBar(binding.toolbar);
}
private void endCall(View view) {
endCall();
}
private void endCall() {
if (this.rtpConnectionReference == null) {
retractSessionProposal();
finish();
} else {
requireRtpConnection().endCall();
}
}
private void retractSessionProposal() {
final Intent intent = getIntent();
final Account account = extractAccount(intent);
final Jid with = Jid.of(intent.getStringExtra(EXTRA_WITH));
resetIntent(account, with, RtpEndUserState.RETRACTED, actionToMedia(intent.getAction()));
xmppConnectionService.getJingleConnectionManager().retractSessionProposal(account, with.asBareJid());
}
private void rejectCall(View view) {
requireRtpConnection().rejectCall();
finish();
}
private void acceptCall(View view) {
requestPermissionsAndAcceptCall();
}
private void requestPermissionsAndAcceptCall() {
final List<String> permissions;
if (getMedia().contains(Media.VIDEO)) {
permissions = ImmutableList.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO);
} else {
permissions = ImmutableList.of(Manifest.permission.RECORD_AUDIO);
}
if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CALL)) {
putScreenInCallMode();
checkRecorderAndAcceptCall();
}
}
private void checkRecorderAndAcceptCall() {
checkMicrophoneAvailability();
requireRtpConnection().acceptCall();
}
private void checkMicrophoneAvailability() {
new Thread(() -> {
final long start = SystemClock.elapsedRealtime();
final boolean isMicrophoneAvailable = AppRTCAudioManager.isMicrophoneAvailable();
final long stop = SystemClock.elapsedRealtime();
Log.d(Config.LOGTAG, "checking microphone availability took " + (stop - start) + "ms");
if (isMicrophoneAvailable) {
return;
}
runOnUiThread(() -> Toast.makeText(this, R.string.microphone_unavailable, Toast.LENGTH_LONG).show());
}
).start();
}
private void putScreenInCallMode() {
putScreenInCallMode(requireRtpConnection().getMedia());
}
private void putScreenInCallMode(final Set<Media> media) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (!media.contains(Media.VIDEO)) {
final JingleRtpConnection rtpConnection = rtpConnectionReference != null ? rtpConnectionReference.get() : null;
final AppRTCAudioManager audioManager = rtpConnection == null ? null : rtpConnection.getAudioManager();
if (audioManager == null || audioManager.getSelectedAudioDevice() == AppRTCAudioManager.AudioDevice.EARPIECE) {
acquireProximityWakeLock();
}
}
}
@SuppressLint("WakelockTimeout")
private void acquireProximityWakeLock() {
final PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (powerManager == null) {
Log.e(Config.LOGTAG, "power manager not available");
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (this.mProximityWakeLock == null) {
this.mProximityWakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, PROXIMITY_WAKE_LOCK_TAG);
}
if (!this.mProximityWakeLock.isHeld()) {
Log.d(Config.LOGTAG, "acquiring proximity wake lock");
this.mProximityWakeLock.acquire();
}
}
}
private void releaseProximityWakeLock() {
if (this.mProximityWakeLock != null && mProximityWakeLock.isHeld()) {
Log.d(Config.LOGTAG, "releasing proximity wake lock");
this.mProximityWakeLock.release();
this.mProximityWakeLock = null;
}
}
private void putProximityWakeLockInProperState() {
if (requireRtpConnection().getAudioManager().getSelectedAudioDevice() == AppRTCAudioManager.AudioDevice.EARPIECE) {
acquireProximityWakeLock();
} else {
releaseProximityWakeLock();
}
}
@Override
protected void refreshUiReal() {
}
@Override
public void onNewIntent(final Intent intent) {
Log.d(Config.LOGTAG, this.getClass().getName() + ".onNewIntent()");
super.onNewIntent(intent);
setIntent(intent);
if (xmppConnectionService == null) {
Log.d(Config.LOGTAG, "RtpSessionActivity: background service wasn't bound in onNewIntent()");
return;
}
final Account account = extractAccount(intent);
final Jid with = Jid.of(intent.getStringExtra(EXTRA_WITH));
final String sessionId = intent.getStringExtra(EXTRA_SESSION_ID);
if (sessionId != null) {
Log.d(Config.LOGTAG, "reinitializing from onNewIntent()");
if (initializeActivityWithRunningRtpSession(account, with, sessionId)) {
return;
}
if (ACTION_ACCEPT_CALL.equals(intent.getAction())) {
Log.d(Config.LOGTAG, "accepting call from onNewIntent()");
requestPermissionsAndAcceptCall();
resetIntent(intent.getExtras());
}
} else {
throw new IllegalStateException("received onNewIntent without sessionId");
}
}
@Override
void onBackendConnected() {
final Intent intent = getIntent();
final String action = intent.getAction();
final Account account = extractAccount(intent);
final Jid with = Jid.of(intent.getStringExtra(EXTRA_WITH));
final String sessionId = intent.getStringExtra(EXTRA_SESSION_ID);
if (sessionId != null) {
if (initializeActivityWithRunningRtpSession(account, with, sessionId)) {
return;
}
if (ACTION_ACCEPT_CALL.equals(intent.getAction())) {
Log.d(Config.LOGTAG, "intent action was accept");
requestPermissionsAndAcceptCall();
resetIntent(intent.getExtras());
}
} else if (asList(ACTION_MAKE_VIDEO_CALL, ACTION_MAKE_VOICE_CALL).contains(action)) {
proposeJingleRtpSession(account, with, actionToMedia(action));
binding.with.setText(account.getRoster().getContact(with).getDisplayName());
} else if (Intent.ACTION_VIEW.equals(action)) {
final String extraLastState = intent.getStringExtra(EXTRA_LAST_REPORTED_STATE);
if (extraLastState != null) {
Log.d(Config.LOGTAG, "restored last state from intent extra");
RtpEndUserState state = RtpEndUserState.valueOf(extraLastState);
updateButtonConfiguration(state);
updateStateDisplay(state);
updateProfilePicture(state);
}
binding.with.setText(account.getRoster().getContact(with).getDisplayName());
}
}
private void proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
checkMicrophoneAvailability();
xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession(account, with, media);
putScreenInCallMode(media);
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (PermissionUtils.allGranted(grantResults)) {
if (requestCode == REQUEST_ACCEPT_CALL) {
checkRecorderAndAcceptCall();
}
} else {
@StringRes int res;
final String firstDenied = getFirstDenied(grantResults, permissions);
if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) {
res = R.string.no_microphone_permission;
} else if (Manifest.permission.CAMERA.equals(firstDenied)) {
res = R.string.no_camera_permission;
} else {
throw new IllegalStateException("Invalid permission result request");
}
Toast.makeText(this, res, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onStop() {
binding.remoteVideo.release();
binding.localVideo.release();
final WeakReference<JingleRtpConnection> weakReference = this.rtpConnectionReference;
final JingleRtpConnection jingleRtpConnection = weakReference == null ? null : weakReference.get();
if (jingleRtpConnection != null) {
releaseVideoTracks(jingleRtpConnection);
} else if (!isChangingConfigurations()) {
if (xmppConnectionService != null) {
retractSessionProposal();
}
}
releaseProximityWakeLock();
super.onStop();
}
private void releaseVideoTracks(final JingleRtpConnection jingleRtpConnection) {
final Optional<VideoTrack> remoteVideo = jingleRtpConnection.getRemoteVideoTrack();
if (remoteVideo.isPresent()) {
remoteVideo.get().removeSink(binding.remoteVideo);
}
final Optional<VideoTrack> localVideo = jingleRtpConnection.geLocalVideoTrack();
if (localVideo.isPresent()) {
localVideo.get().removeSink(binding.localVideo);
}
}
@Override
public void onBackPressed() {
endCall();
super.onBackPressed();
}
@Override
public void onUserLeaveHint() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && deviceSupportsPictureInPicture()) {
if (shouldBePictureInPicture()) {
startPictureInPicture();
}
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
private void startPictureInPicture() {
try {
enterPictureInPictureMode(
new PictureInPictureParams.Builder()
.setAspectRatio(new Rational(10, 16))
.build()
);
} catch (IllegalStateException e) {
//this sometimes happens on Samsung phones (possibly when Knox is enabled)
Log.w(Config.LOGTAG, "unable to enter picture in picture mode", e);
}
}
private boolean deviceSupportsPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
} else {
return false;
}
}
private boolean shouldBePictureInPicture() {
try {
final JingleRtpConnection rtpConnection = requireRtpConnection();
return rtpConnection.getMedia().contains(Media.VIDEO) && Arrays.asList(
RtpEndUserState.ACCEPTING_CALL,
RtpEndUserState.CONNECTING,
RtpEndUserState.CONNECTED
).contains(rtpConnection.getEndUserState());
} catch (IllegalStateException e) {
return false;
}
}
private boolean initializeActivityWithRunningRtpSession(final Account account, Jid with, String sessionId) {
final WeakReference<JingleRtpConnection> reference = xmppConnectionService.getJingleConnectionManager()
.findJingleRtpConnection(account, with, sessionId);
if (reference == null || reference.get() == null) {
finish();
return true;
}
this.rtpConnectionReference = reference;
final RtpEndUserState currentState = requireRtpConnection().getEndUserState();
if (currentState == RtpEndUserState.ENDED) {
finish();
return true;
}
if (currentState == RtpEndUserState.INCOMING_CALL) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
if (JingleRtpConnection.STATES_SHOWING_ONGOING_CALL.contains(requireRtpConnection().getState())) {
putScreenInCallMode();
}
binding.with.setText(getWith().getDisplayName());
updateVideoViews(currentState);
updateStateDisplay(currentState);
updateButtonConfiguration(currentState);
updateProfilePicture(currentState);
return false;
}
private void reInitializeActivityWithRunningRapSession(final Account account, Jid with, String sessionId) {
runOnUiThread(() -> {
initializeActivityWithRunningRtpSession(account, with, sessionId);
});
final Intent intent = new Intent(Intent.ACTION_VIEW);
intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString());
intent.putExtra(EXTRA_WITH, with.toEscapedString());
intent.putExtra(EXTRA_SESSION_ID, sessionId);
setIntent(intent);
}
private void ensureSurfaceViewRendererIsSetup(final SurfaceViewRenderer surfaceViewRenderer) {
surfaceViewRenderer.setVisibility(View.VISIBLE);
try {
surfaceViewRenderer.init(requireRtpConnection().getEglBaseContext(), null);
} catch (IllegalStateException e) {
Log.d(Config.LOGTAG, "SurfaceViewRenderer was already initialized");
}
surfaceViewRenderer.setEnableHardwareScaler(true);
}
private void updateStateDisplay(final RtpEndUserState state) {
switch (state) {
case INCOMING_CALL:
if (getMedia().contains(Media.VIDEO)) {
setTitle(R.string.rtp_state_incoming_video_call);
} else {
setTitle(R.string.rtp_state_incoming_call);
}
break;
case CONNECTING:
setTitle(R.string.rtp_state_connecting);
break;
case CONNECTED:
setTitle(R.string.rtp_state_connected);
break;
case ACCEPTING_CALL:
setTitle(R.string.rtp_state_accepting_call);
break;
case ENDING_CALL:
setTitle(R.string.rtp_state_ending_call);
break;
case FINDING_DEVICE:
setTitle(R.string.rtp_state_finding_device);
break;
case RINGING:
setTitle(R.string.rtp_state_ringing);
break;
case DECLINED_OR_BUSY:
setTitle(R.string.rtp_state_declined_or_busy);
break;
case CONNECTIVITY_ERROR:
setTitle(R.string.rtp_state_connectivity_error);
break;
case RETRACTED:
setTitle(R.string.rtp_state_retracted);
break;
case APPLICATION_ERROR:
setTitle(R.string.rtp_state_application_failure);
break;
case ENDED:
throw new IllegalStateException("Activity should have called finishAndReleaseWakeLock();");
default:
throw new IllegalStateException(String.format("State %s has not been handled in UI", state));
}
}
private void updateProfilePicture(final RtpEndUserState state) {
if (state == RtpEndUserState.INCOMING_CALL || state == RtpEndUserState.ACCEPTING_CALL) {
final boolean show = getResources().getBoolean(R.bool.show_avatar_incoming_call);
if (show) {
binding.contactPhoto.setVisibility(View.VISIBLE);
AvatarWorkerTask.loadAvatar(getWith(), binding.contactPhoto, R.dimen.publish_avatar_size);
} else {
binding.contactPhoto.setVisibility(View.GONE);
}
} else {
binding.contactPhoto.setVisibility(View.GONE);
}
}
private Set<Media> getMedia() {
return requireRtpConnection().getMedia();
}
@SuppressLint("RestrictedApi")
private void updateButtonConfiguration(final RtpEndUserState state) {
if (state == RtpEndUserState.ENDING_CALL || isPictureInPicture()) {
this.binding.rejectCall.setVisibility(View.INVISIBLE);
this.binding.endCall.setVisibility(View.INVISIBLE);
this.binding.acceptCall.setVisibility(View.INVISIBLE);
} else if (state == RtpEndUserState.INCOMING_CALL) {
this.binding.rejectCall.setOnClickListener(this::rejectCall);
this.binding.rejectCall.setImageResource(R.drawable.ic_call_end_white_48dp);
this.binding.rejectCall.setVisibility(View.VISIBLE);
this.binding.endCall.setVisibility(View.INVISIBLE);
this.binding.acceptCall.setOnClickListener(this::acceptCall);
this.binding.acceptCall.setImageResource(R.drawable.ic_call_white_48dp);
this.binding.acceptCall.setVisibility(View.VISIBLE);
} else if (state == RtpEndUserState.DECLINED_OR_BUSY) {
this.binding.rejectCall.setVisibility(View.INVISIBLE);
this.binding.endCall.setOnClickListener(this::exit);
this.binding.endCall.setImageResource(R.drawable.ic_clear_white_48dp);
this.binding.endCall.setVisibility(View.VISIBLE);
this.binding.acceptCall.setVisibility(View.INVISIBLE);
} else if (asList(RtpEndUserState.CONNECTIVITY_ERROR, RtpEndUserState.APPLICATION_ERROR, RtpEndUserState.RETRACTED).contains(state)) {
this.binding.rejectCall.setOnClickListener(this::exit);
this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp);
this.binding.rejectCall.setVisibility(View.VISIBLE);
this.binding.endCall.setVisibility(View.INVISIBLE);
this.binding.acceptCall.setOnClickListener(this::retry);
this.binding.acceptCall.setImageResource(R.drawable.ic_replay_white_48dp);
this.binding.acceptCall.setVisibility(View.VISIBLE);
} else {
this.binding.rejectCall.setVisibility(View.INVISIBLE);
this.binding.endCall.setOnClickListener(this::endCall);
this.binding.endCall.setImageResource(R.drawable.ic_call_end_white_48dp);
this.binding.endCall.setVisibility(View.VISIBLE);
this.binding.acceptCall.setVisibility(View.INVISIBLE);
}
updateInCallButtonConfiguration(state);
}
private boolean isPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return isInPictureInPictureMode();
} else {
return false;
}
}
private void updateInCallButtonConfiguration() {
updateInCallButtonConfiguration(requireRtpConnection().getEndUserState());
}
@SuppressLint("RestrictedApi")
private void updateInCallButtonConfiguration(final RtpEndUserState state) {
if (state == RtpEndUserState.CONNECTED && !isPictureInPicture()) {
if (getMedia().contains(Media.VIDEO)) {
updateInCallButtonConfigurationVideo(requireRtpConnection().isVideoEnabled());
} else {
final AppRTCAudioManager audioManager = requireRtpConnection().getAudioManager();
updateInCallButtonConfigurationSpeaker(
audioManager.getSelectedAudioDevice(),
audioManager.getAudioDevices().size()
);
}
updateInCallButtonConfigurationMicrophone(requireRtpConnection().isMicrophoneEnabled());
} else {
this.binding.inCallActionLeft.setVisibility(View.GONE);
this.binding.inCallActionRight.setVisibility(View.GONE);
}
}
@SuppressLint("RestrictedApi")
private void updateInCallButtonConfigurationSpeaker(final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) {
switch (selectedAudioDevice) {
case EARPIECE:
this.binding.inCallActionRight.setImageResource(R.drawable.ic_volume_off_black_24dp);
if (numberOfChoices >= 2) {
this.binding.inCallActionRight.setOnClickListener(this::switchToSpeaker);
} else {
this.binding.inCallActionRight.setOnClickListener(null);
this.binding.inCallActionRight.setClickable(false);
}
break;
case WIRED_HEADSET:
this.binding.inCallActionRight.setImageResource(R.drawable.ic_headset_black_24dp);
this.binding.inCallActionRight.setOnClickListener(null);
this.binding.inCallActionRight.setClickable(false);
break;
case SPEAKER_PHONE:
this.binding.inCallActionRight.setImageResource(R.drawable.ic_volume_up_black_24dp);
if (numberOfChoices >= 2) {
this.binding.inCallActionRight.setOnClickListener(this::switchToEarpiece);
} else {
this.binding.inCallActionRight.setOnClickListener(null);
this.binding.inCallActionRight.setClickable(false);
}
break;
case BLUETOOTH:
this.binding.inCallActionRight.setImageResource(R.drawable.ic_bluetooth_audio_black_24dp);
this.binding.inCallActionRight.setOnClickListener(null);
this.binding.inCallActionRight.setClickable(false);
break;
}
this.binding.inCallActionRight.setVisibility(View.VISIBLE);
}
@SuppressLint("RestrictedApi")
private void updateInCallButtonConfigurationVideo(final boolean videoEnabled) {
this.binding.inCallActionRight.setVisibility(View.VISIBLE);
if (videoEnabled) {
this.binding.inCallActionRight.setImageResource(R.drawable.ic_videocam_black_24dp);
this.binding.inCallActionRight.setOnClickListener(this::disableVideo);
} else {
this.binding.inCallActionRight.setImageResource(R.drawable.ic_videocam_off_black_24dp);
this.binding.inCallActionRight.setOnClickListener(this::enableVideo);
}
}
private void enableVideo(View view) {
requireRtpConnection().setVideoEnabled(true);
updateInCallButtonConfigurationVideo(true);
}
private void disableVideo(View view) {
requireRtpConnection().setVideoEnabled(false);
updateInCallButtonConfigurationVideo(false);
}
@SuppressLint("RestrictedApi")
private void updateInCallButtonConfigurationMicrophone(final boolean microphoneEnabled) {
if (microphoneEnabled) {
this.binding.inCallActionLeft.setImageResource(R.drawable.ic_mic_black_24dp);
this.binding.inCallActionLeft.setOnClickListener(this::disableMicrophone);
} else {
this.binding.inCallActionLeft.setImageResource(R.drawable.ic_mic_off_black_24dp);
this.binding.inCallActionLeft.setOnClickListener(this::enableMicrophone);
}
this.binding.inCallActionLeft.setVisibility(View.VISIBLE);
}
private void updateVideoViews(final RtpEndUserState state) {
if (END_CARD.contains(state) || state == RtpEndUserState.ENDING_CALL) {
binding.localVideo.setVisibility(View.GONE);
binding.remoteVideo.setVisibility(View.GONE);
binding.pipLocalMicOffIndicator.setVisibility(View.GONE);
if (isPictureInPicture()) {
binding.appBarLayout.setVisibility(View.GONE);
binding.pipPlaceholder.setVisibility(View.VISIBLE);
if (state == RtpEndUserState.APPLICATION_ERROR || state == RtpEndUserState.CONNECTIVITY_ERROR) {
binding.pipWarning.setVisibility(View.VISIBLE);
binding.pipWaiting.setVisibility(View.GONE);
} else {
binding.pipWarning.setVisibility(View.GONE);
binding.pipWaiting.setVisibility(View.GONE);
}
} else {
binding.appBarLayout.setVisibility(View.VISIBLE);
binding.pipPlaceholder.setVisibility(View.GONE);
}
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
return;
}
if (isPictureInPicture() && (state == RtpEndUserState.CONNECTING || state == RtpEndUserState.ACCEPTING_CALL)) {
binding.localVideo.setVisibility(View.GONE);
binding.remoteVideo.setVisibility(View.GONE);
binding.appBarLayout.setVisibility(View.GONE);
binding.pipPlaceholder.setVisibility(View.VISIBLE);
binding.pipWarning.setVisibility(View.GONE);
binding.pipWaiting.setVisibility(View.VISIBLE);
binding.pipLocalMicOffIndicator.setVisibility(View.GONE);
return;
}
final Optional<VideoTrack> localVideoTrack = requireRtpConnection().geLocalVideoTrack();
if (localVideoTrack.isPresent() && !isPictureInPicture()) {
ensureSurfaceViewRendererIsSetup(binding.localVideo);
//paint local view over remote view
binding.localVideo.setZOrderMediaOverlay(true);
binding.localVideo.setMirror(true);
localVideoTrack.get().addSink(binding.localVideo);
} else {
binding.localVideo.setVisibility(View.GONE);
}
final Optional<VideoTrack> remoteVideoTrack = requireRtpConnection().getRemoteVideoTrack();
if (remoteVideoTrack.isPresent()) {
ensureSurfaceViewRendererIsSetup(binding.remoteVideo);
remoteVideoTrack.get().addSink(binding.remoteVideo);
if (state == RtpEndUserState.CONNECTED) {
binding.appBarLayout.setVisibility(View.GONE);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
binding.remoteVideo.setVisibility(View.GONE);
}
if (isPictureInPicture() && !requireRtpConnection().isMicrophoneEnabled()) {
binding.pipLocalMicOffIndicator.setVisibility(View.VISIBLE);
} else {
binding.pipLocalMicOffIndicator.setVisibility(View.GONE);
}
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
binding.remoteVideo.setVisibility(View.GONE);
binding.pipLocalMicOffIndicator.setVisibility(View.GONE);
}
}
private void disableMicrophone(View view) {
JingleRtpConnection rtpConnection = requireRtpConnection();
rtpConnection.setMicrophoneEnabled(false);
updateInCallButtonConfiguration();
}
private void enableMicrophone(View view) {
JingleRtpConnection rtpConnection = requireRtpConnection();
rtpConnection.setMicrophoneEnabled(true);
updateInCallButtonConfiguration();
}
private void switchToEarpiece(View view) {
requireRtpConnection().getAudioManager().setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.EARPIECE);
acquireProximityWakeLock();
}
private void switchToSpeaker(View view) {
requireRtpConnection().getAudioManager().setDefaultAudioDevice(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
releaseProximityWakeLock();
}
private void retry(View view) {
Log.d(Config.LOGTAG, "attempting retry");
final Intent intent = getIntent();
final Account account = extractAccount(intent);
final Jid with = Jid.of(intent.getStringExtra(EXTRA_WITH));
final String lastAction = intent.getStringExtra(EXTRA_LAST_ACTION);
final String action = intent.getAction();
final Set<Media> media = actionToMedia(lastAction == null ? action : lastAction);
this.rtpConnectionReference = null;
proposeJingleRtpSession(account, with, media);
}
private void exit(View view) {
finish();
}
private Contact getWith() {
final AbstractJingleConnection.Id id = requireRtpConnection().getId();
final Account account = id.account;
return account.getRoster().getContact(id.with);
}
private JingleRtpConnection requireRtpConnection() {
final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
if (connection == null) {
throw new IllegalStateException("No RTP connection found");
}
return connection;
}
@Override
public void onJingleRtpConnectionUpdate(Account account, Jid with, final String sessionId, RtpEndUserState state) {
Log.d(Config.LOGTAG, "onJingleRtpConnectionUpdate(" + state + ")");
if (END_CARD.contains(state)) {
Log.d(Config.LOGTAG, "end card reached");
releaseProximityWakeLock();
runOnUiThread(() -> getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON));
}
if (with.isBareJid()) {
updateRtpSessionProposalState(account, with, state);
return;
}
if (this.rtpConnectionReference == null) {
if (END_CARD.contains(state)) {
Log.d(Config.LOGTAG, "not reinitializing session");
return;
}
//this happens when going from proposed session to actual session
reInitializeActivityWithRunningRapSession(account, with, sessionId);
return;
}
final AbstractJingleConnection.Id id = requireRtpConnection().getId();
if (account == id.account && id.with.equals(with) && id.sessionId.equals(sessionId)) {
if (state == RtpEndUserState.ENDED) {
finish();
return;
}
runOnUiThread(() -> {
updateStateDisplay(state);
updateButtonConfiguration(state);
updateVideoViews(state);
updateProfilePicture(state);
});
if (END_CARD.contains(state)) {
resetIntent(account, with, state, requireRtpConnection().getMedia());
this.rtpConnectionReference = null;
}
} else {
Log.d(Config.LOGTAG, "received update for other rtp session");
}
}
@Override
public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
Log.d(Config.LOGTAG, "onAudioDeviceChanged in activity: selected:" + selectedAudioDevice + ", available:" + availableAudioDevices);
try {
if (getMedia().contains(Media.VIDEO)) {
Log.d(Config.LOGTAG, "nothing to do; in video mode");
return;
}
final RtpEndUserState endUserState = requireRtpConnection().getEndUserState();
if (endUserState == RtpEndUserState.CONNECTED) {
final AppRTCAudioManager audioManager = requireRtpConnection().getAudioManager();
updateInCallButtonConfigurationSpeaker(
audioManager.getSelectedAudioDevice(),
audioManager.getAudioDevices().size()
);
} else if (END_CARD.contains(endUserState)) {
Log.d(Config.LOGTAG, "onAudioDeviceChanged() nothing to do because end card has been reached");
} else {
putProximityWakeLockInProperState();
}
} catch (IllegalStateException e) {
Log.d(Config.LOGTAG, "RTP connection was not available when audio device changed");
}
}
private void updateRtpSessionProposalState(final Account account, final Jid with, final RtpEndUserState state) {
final Intent currentIntent = getIntent();
final String withExtra = currentIntent == null ? null : currentIntent.getStringExtra(EXTRA_WITH);
if (withExtra == null) {
return;
}
if (Jid.ofEscaped(withExtra).asBareJid().equals(with)) {
runOnUiThread(() -> {
updateStateDisplay(state);
updateButtonConfiguration(state);
updateProfilePicture(state);
});
resetIntent(account, with, state, actionToMedia(currentIntent.getAction()));
}
}
private void resetIntent(final Bundle extras) {
final Intent intent = new Intent(Intent.ACTION_VIEW);
intent.putExtras(extras);
setIntent(intent);
}
private void resetIntent(final Account account, Jid with, final RtpEndUserState state, final Set<Media> media) {
final Intent intent = new Intent(Intent.ACTION_VIEW);
intent.putExtra(EXTRA_WITH, with.asBareJid().toEscapedString());
intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString());
intent.putExtra(EXTRA_LAST_REPORTED_STATE, state.toString());
intent.putExtra(EXTRA_LAST_ACTION, media.contains(Media.VIDEO) ? ACTION_MAKE_VIDEO_CALL : ACTION_MAKE_VOICE_CALL);
setIntent(intent);
}
}

View File

@ -67,6 +67,7 @@ import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.Presences;
import eu.siacs.conversations.services.AvatarService;
import eu.siacs.conversations.services.BarcodeProvider;
import eu.siacs.conversations.services.QuickConversationsService;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder;
import eu.siacs.conversations.ui.service.EmojiService;
@ -96,6 +97,7 @@ public abstract class XmppActivity extends ActionBarActivity {
protected int mTheme;
protected boolean mUsingEnterKey = false;
protected boolean mUseTor = false;
protected Toast mToast;
public Runnable onOpenPGPKeyPublished = () -> Toast.makeText(XmppActivity.this, R.string.openpgp_has_been_published, Toast.LENGTH_SHORT).show();
protected ConferenceInvite mPendingConferenceInvite = null;
@ -211,6 +213,8 @@ public abstract class XmppActivity extends ActionBarActivity {
this.registerListeners();
this.onBackendConnected();
}
this.mUsingEnterKey = usingEnterKey();
this.mUseTor = useTor();
}
public void connectToBackend() {
@ -305,6 +309,9 @@ public abstract class XmppActivity extends ActionBarActivity {
if (this instanceof OnKeyStatusUpdated) {
this.xmppConnectionService.setOnKeyStatusUpdatedListener((OnKeyStatusUpdated) this);
}
if (this instanceof XmppConnectionService.OnJingleRtpConnectionUpdate) {
this.xmppConnectionService.setOnRtpConnectionUpdateListener((XmppConnectionService.OnJingleRtpConnectionUpdate) this);
}
}
protected void unregisterListeners() {
@ -332,6 +339,9 @@ public abstract class XmppActivity extends ActionBarActivity {
if (this instanceof OnKeyStatusUpdated) {
this.xmppConnectionService.removeOnNewKeysAvailableListener((OnKeyStatusUpdated) this);
}
if (this instanceof XmppConnectionService.OnJingleRtpConnectionUpdate) {
this.xmppConnectionService.removeRtpConnectionUpdateListener((XmppConnectionService.OnJingleRtpConnectionUpdate) this);
}
}
@Override
@ -388,17 +398,20 @@ public abstract class XmppActivity extends ActionBarActivity {
}
}
@SuppressLint("UnsupportedChromeOsCameraSystemFeature")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
metrics = getResources().getDisplayMetrics();
ExceptionHelper.init(getApplicationContext());
new EmojiService(this).init();
this.isCameraFeatureAvailable = getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
this.isCameraFeatureAvailable = getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
} else {
this.isCameraFeatureAvailable = getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA);
}
this.mTheme = findTheme();
setTheme(this.mTheme);
this.mUsingEnterKey = usingEnterKey();
}
protected boolean isCameraFeatureAvailable() {
@ -440,10 +453,14 @@ public abstract class XmppActivity extends ActionBarActivity {
}
}
protected boolean usingEnterKey() {
private boolean usingEnterKey() {
return getBooleanPreference("display_enter_key", R.bool.display_enter_key);
}
private boolean useTor() {
return QuickConversationsService.isConversations() && getBooleanPreference("use_tor", R.bool.use_tor);
}
protected SharedPreferences getPreferences() {
return PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
}
@ -851,7 +868,7 @@ public abstract class XmppActivity extends ActionBarActivity {
}
protected Account extractAccount(Intent intent) {
String jid = intent != null ? intent.getStringExtra(EXTRA_ACCOUNT) : null;
final String jid = intent != null ? intent.getStringExtra(EXTRA_ACCOUNT) : null;
try {
return jid != null ? xmppConnectionService.findAccountByJid(Jid.of(jid)) : null;
} catch (IllegalArgumentException e) {

View File

@ -0,0 +1,55 @@
/*
* 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

@ -30,8 +30,13 @@ public class Compatibility {
"led",
"notification_ringtone",
"notification_headsup",
"vibrate_on_notification");
private static final List<String> UNUESD_SETTINGS_PRE_TWENTYSIX = Collections.singletonList("more_notification_settings");
"vibrate_on_notification",
"call_ringtone"
);
private static final List<String> UNUESD_SETTINGS_PRE_TWENTYSIX = Arrays.asList(
"message_notification_settings",
"call_notification_settings"
);
public static boolean hasStoragePermission(Context context) {
@ -131,7 +136,7 @@ public class Compatibility {
context.startService(intent);
}
} catch (RuntimeException e) {
Log.d(Config.LOGTAG, context.getClass().getSimpleName()+" was unable to start service");
Log.d(Config.LOGTAG, context.getClass().getSimpleName() + " was unable to start service");
}
}
}

View File

@ -16,7 +16,6 @@
package eu.siacs.conversations.utils;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import java.io.File;
@ -274,6 +273,8 @@ public final class MimeUtils {
add("image/jpeg", "jpg");
add("image/jpeg", "jpeg");
add("image/jpeg", "jpe");
add("image/jpeg", "jfif");
add("image/jpeg", "jif");
add("image/pcx", "pcx");
add("image/png", "png");
add("image/svg+xml", "svg");

View File

@ -1,7 +1,14 @@
package eu.siacs.conversations.utils;
import android.Manifest;
import android.app.Activity;
import android.content.pm.PackageManager;
import android.os.Build;
import android.support.v4.app.ActivityCompat;
import com.google.common.collect.ImmutableList;
import java.util.List;
public class PermissionUtils {
@ -31,4 +38,23 @@ public class PermissionUtils {
}
return null;
}
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

@ -31,6 +31,7 @@ import eu.siacs.conversations.entities.ListItem;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.RtpSessionStatus;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.services.ExportBackupService;
import rocks.xmpp.addr.Jid;
@ -299,6 +300,14 @@ public class UIHelper {
return new Pair<>(context.getString(R.string.omemo_decryption_failed), true);
} else if (message.isFileOrImage()) {
return new Pair<>(getFileDescriptionString(context, message), true);
} else if (message.getType() == Message.TYPE_RTP_SESSION) {
RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
final boolean received = message.getStatus() == Message.STATUS_RECEIVED;
if (!rtpSessionStatus.successful && received) {
return new Pair<>(context.getString(R.string.missed_call),true);
} else {
return new Pair<>(context.getString(received ? R.string.incoming_call : R.string.outgoing_call), true);
}
} else {
final String body = MessageUtils.filterLtrRtl(message.getBody());
if (body.startsWith(Message.ME_COMMAND)) {

View File

@ -1,42 +1,55 @@
package eu.siacs.conversations.xml;
public final class Namespace {
public static final String DISCO_ITEMS = "http://jabber.org/protocol/disco#items";
public static final String DISCO_INFO = "http://jabber.org/protocol/disco#info";
public static final String BLOCKING = "urn:xmpp:blocking";
public static final String ROSTER = "jabber:iq:roster";
public static final String REGISTER = "jabber:iq:register";
public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams";
public static final String HTTP_UPLOAD = "urn:xmpp:http:upload:0";
public static final String HTTP_UPLOAD_LEGACY = "urn:xmpp:http:upload";
public static final String STANZA_IDS = "urn:xmpp:sid:0";
public static final String IDLE = "urn:xmpp:idle:1";
public static final String DATA = "jabber:x:data";
public static final String OOB = "jabber:x:oob";
public static final String SASL = "urn:ietf:params:xml:ns:xmpp-sasl";
public static final String TLS = "urn:ietf:params:xml:ns:xmpp-tls";
public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB+"#publish-options";
public static final String PUBSUB_ERROR = PUBSUB+"#errors";
public static final String PUBSUB_OWNER = PUBSUB+"#owner";
public static final String NICK = "http://jabber.org/protocol/nick";
public static final String FLEXIBLE_OFFLINE_MESSAGE_RETRIEVAL = "http://jabber.org/protocol/offline";
public static final String BIND = "urn:ietf:params:xml:ns:xmpp-bind";
public static final String P1_S3_FILE_TRANSFER = "p1:s3filetransfer";
public static final String BOOKMARKS_CONVERSION = "urn:xmpp:bookmarks-conversion:0";
public static final String BOOKMARKS = "storage:bookmarks";
public static final String SYNCHRONIZATION = "im.quicksy.synchronization:0";
public static final String AVATAR_CONVERSION = "urn:xmpp:pep-vcard-conversion:0";
public static final String JINGLE_TRANSPORTS_S5B = "urn:xmpp:jingle:transports:s5b:1";
public static final String JINGLE_TRANSPORTS_IBB = "urn:xmpp:jingle:transports:ibb:1";
public static final String PING = "urn:xmpp:ping";
public static final String PUSH = "urn:xmpp:push:0";
public static final String COMMANDS = "http://jabber.org/protocol/commands";
public static final String JINGLE_ENCRYPTED_TRANSPORT = "urn:xmpp:jingle:jet:0";
public static final String JINGLE_ENCRYPTED_TRANSPORT_OMEMO = "urn:xmpp:jingle:jet-omemo:0";
public static final String MUC_USER = "http://jabber.org/protocol/muc#user";
public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:0";
public static final String BOOKMARKS2_COMPAT = BOOKMARKS2+"#compat";
public static final String INVITE = "urn:xmpp:invite";
public static final String PARS = "urn:xmpp:pars:0";
public static final String DISCO_ITEMS = "http://jabber.org/protocol/disco#items";
public static final String DISCO_INFO = "http://jabber.org/protocol/disco#info";
public static final String EXTERNAL_SERVICE_DISCOVERY = "urn:xmpp:extdisco:2";
public static final String BLOCKING = "urn:xmpp:blocking";
public static final String ROSTER = "jabber:iq:roster";
public static final String REGISTER = "jabber:iq:register";
public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams";
public static final String HTTP_UPLOAD = "urn:xmpp:http:upload:0";
public static final String HTTP_UPLOAD_LEGACY = "urn:xmpp:http:upload";
public static final String STANZA_IDS = "urn:xmpp:sid:0";
public static final String IDLE = "urn:xmpp:idle:1";
public static final String DATA = "jabber:x:data";
public static final String OOB = "jabber:x:oob";
public static final String SASL = "urn:ietf:params:xml:ns:xmpp-sasl";
public static final String TLS = "urn:ietf:params:xml:ns:xmpp-tls";
public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options";
public static final String PUBSUB_ERROR = PUBSUB + "#errors";
public static final String PUBSUB_OWNER = PUBSUB + "#owner";
public static final String NICK = "http://jabber.org/protocol/nick";
public static final String FLEXIBLE_OFFLINE_MESSAGE_RETRIEVAL = "http://jabber.org/protocol/offline";
public static final String BIND = "urn:ietf:params:xml:ns:xmpp-bind";
public static final String P1_S3_FILE_TRANSFER = "p1:s3filetransfer";
public static final String BOOKMARKS_CONVERSION = "urn:xmpp:bookmarks-conversion:0";
public static final String BOOKMARKS = "storage:bookmarks";
public static final String SYNCHRONIZATION = "im.quicksy.synchronization:0";
public static final String AVATAR_CONVERSION = "urn:xmpp:pep-vcard-conversion:0";
public static final String JINGLE = "urn:xmpp:jingle:1";
public static final String JINGLE_MESSAGE = "urn:xmpp:jingle-message:0";
public static final String JINGLE_ENCRYPTED_TRANSPORT = "urn:xmpp:jingle:jet:0";
public static final String JINGLE_ENCRYPTED_TRANSPORT_OMEMO = "urn:xmpp:jingle:jet-omemo:0";
public static final String JINGLE_TRANSPORTS_S5B = "urn:xmpp:jingle:transports:s5b:1";
public static final String JINGLE_TRANSPORTS_IBB = "urn:xmpp:jingle:transports:ibb:1";
public static final String JINGLE_TRANSPORT_ICE_UDP = "urn:xmpp:jingle:transports:ice-udp:1";
public static final String JINGLE_APPS_RTP = "urn:xmpp:jingle:apps:rtp:1";
public static final String JINGLE_APPS_DTLS = "urn:xmpp:jingle:apps:dtls:0";
public static final String JINGLE_APPS_GROUPING = "urn:xmpp:jingle:apps:grouping:0";
public static final String JINGLE_FEATURE_AUDIO = "urn:xmpp:jingle:apps:rtp:audio";
public static final String JINGLE_FEATURE_VIDEO = "urn:xmpp:jingle:apps:rtp:video";
public static final String JINGLE_RTP_HEADER_EXTENSIONS = "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0";
public static final String JINGLE_RTP_FEEDBACK_NEGOTIATION = "urn:xmpp:jingle:apps:rtp:rtcp-fb:0";
public static final String JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES = "urn:xmpp:jingle:apps:rtp:ssma:0";
public static final String IBB = "http://jabber.org/protocol/ibb";
public static final String PING = "urn:xmpp:ping";
public static final String PUSH = "urn:xmpp:push:0";
public static final String COMMANDS = "http://jabber.org/protocol/commands";
public static final String MUC_USER = "http://jabber.org/protocol/muc#user";
public static final String BOOKMARKS2 = "urn:xmpp:bookmarks:0";
public static final String BOOKMARKS2_COMPAT = BOOKMARKS2 + "#compat";
public static final String INVITE = "urn:xmpp:invite";
public static final String PARS = "urn:xmpp:pars:0";
}

View File

@ -646,8 +646,8 @@ public class XmppConnection implements Runnable {
}
private @NonNull
Element processPacket(final Tag currentTag, final int packetType) throws XmlPullParserException, IOException {
Element element;
Element processPacket(final Tag currentTag, final int packetType) throws IOException {
final Element element;
switch (packetType) {
case PACKET_IQ:
element = new IqPacket();
@ -668,16 +668,7 @@ public class XmppConnection implements Runnable {
}
while (!nextTag.isEnd(element.getName())) {
if (!nextTag.isNo()) {
final Element child = tagReader.readElement(nextTag);
final String type = currentTag.getAttribute("type");
if (packetType == PACKET_IQ
&& "jingle".equals(child.getName())
&& ("set".equalsIgnoreCase(type) || "get"
.equalsIgnoreCase(type))) {
element = new JinglePacket();
element.setAttributes(currentTag.getAttributes());
}
element.addChild(child);
element.addChild(tagReader.readElement(nextTag));
}
nextTag = tagReader.readTag();
if (nextTag == null) {
@ -697,10 +688,14 @@ public class XmppConnection implements Runnable {
if (Config.BACKGROUND_STANZA_LOGGING && mXmppConnectionService.checkListeners()) {
Log.d(Config.LOGTAG, "[background stanza] " + element);
}
return element;
if (element instanceof IqPacket && element.hasChild("jingle", Namespace.JINGLE)) {
return JinglePacket.upgrade((IqPacket) element);
} else {
return element;
}
}
private void processIq(final Tag currentTag) throws XmlPullParserException, IOException {
private void processIq(final Tag currentTag) throws IOException {
final IqPacket packet = (IqPacket) processPacket(currentTag, PACKET_IQ);
if (!packet.valid()) {
Log.e(Config.LOGTAG, "encountered invalid iq from='" + packet.getFrom() + "' to='" + packet.getTo() + "'");
@ -713,8 +708,8 @@ public class XmppConnection implements Runnable {
} else {
OnIqPacketReceived callback = null;
synchronized (this.packetCallbacks) {
if (packetCallbacks.containsKey(packet.getId())) {
final Pair<IqPacket, OnIqPacketReceived> packetCallbackDuple = packetCallbacks.get(packet.getId());
final Pair<IqPacket, OnIqPacketReceived> packetCallbackDuple = packetCallbacks.get(packet.getId());
if (packetCallbackDuple != null) {
// Packets to the server should have responses from the server
if (packetCallbackDuple.first.toServer(account)) {
if (packet.fromServer(account)) {
@ -1884,5 +1879,9 @@ public class XmppConnection implements Runnable {
public boolean bookmarks2() {
return Config.USE_BOOKMARKS2 /* || hasDiscoFeature(account.getJid().asBareJid(), Namespace.BOOKMARKS2_COMPAT)*/;
}
public boolean externalServiceDiscovery() {
return hasDiscoFeature(Jid.of(account.getServer()),Namespace.EXTERNAL_SERVICE_DISCOVERY);
}
}
}

View File

@ -0,0 +1,109 @@
package eu.siacs.conversations.xmpp.jingle;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import rocks.xmpp.addr.Jid;
public abstract class AbstractJingleConnection {
public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-";
public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-";
protected final JingleConnectionManager jingleConnectionManager;
protected final XmppConnectionService xmppConnectionService;
protected final Id id;
protected final Jid initiator;
AbstractJingleConnection(final JingleConnectionManager jingleConnectionManager, final Id id, final Jid initiator) {
this.jingleConnectionManager = jingleConnectionManager;
this.xmppConnectionService = jingleConnectionManager.getXmppConnectionService();
this.id = id;
this.initiator = initiator;
}
boolean isInitiator() {
return initiator.equals(id.account.getJid());
}
abstract void deliverPacket(JinglePacket jinglePacket);
public Id getId() {
return id;
}
abstract void notifyRebound();
public static class Id {
public final Account account;
public final Jid with;
public final String sessionId;
private Id(final Account account, final Jid with, final String sessionId) {
Preconditions.checkNotNull(with);
Preconditions.checkArgument(with.isFullJid());
this.account = account;
this.with = with;
this.sessionId = sessionId;
}
public static Id of(Account account, JinglePacket jinglePacket) {
return new Id(account, jinglePacket.getFrom(), jinglePacket.getSessionId());
}
public static Id of(Account account, Jid with, final String sessionId) {
return new Id(account, with, sessionId);
}
public static Id of(Message message) {
return new Id(
message.getConversation().getAccount(),
message.getCounterpart(),
JingleConnectionManager.nextRandomId()
);
}
public Contact getContact() {
return account.getRoster().getContact(with);
}
@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(account.getJid(), id.account.getJid()) &&
Objects.equal(with, id.with) &&
Objects.equal(sessionId, id.sessionId);
}
@Override
public int hashCode() {
return Objects.hashCode(account.getJid(), with, sessionId);
}
}
public enum State {
NULL, //default value; nothing has been sent or received yet
PROPOSED,
ACCEPTED,
PROCEED,
REJECTED,
RETRACTED,
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
}
}

View File

@ -101,22 +101,24 @@ public class JingleCandidate {
return this.type;
}
public static List<JingleCandidate> parse(List<Element> canditates) {
List<JingleCandidate> parsedCandidates = new ArrayList<>();
for (Element c : canditates) {
parsedCandidates.add(JingleCandidate.parse(c));
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 parsedCandidates;
return candidates;
}
public static JingleCandidate parse(Element candidate) {
JingleCandidate parsedCandidate = new JingleCandidate(candidate.getAttribute("cid"), false);
parsedCandidate.setHost(candidate.getAttribute("host"));
parsedCandidate.setJid(InvalidJid.getNullForInvalid(candidate.getAttributeAsJid("jid")));
parsedCandidate.setType(candidate.getAttribute("type"));
parsedCandidate.setPriority(Integer.parseInt(candidate.getAttribute("priority")));
parsedCandidate.setPort(Integer.parseInt(candidate.getAttribute("port")));
return parsedCandidate;
public static JingleCandidate parse(Element element) {
final JingleCandidate candidate = new JingleCandidate(element.getAttribute("cid"), false);
candidate.setHost(element.getAttribute("host"));
candidate.setJid(InvalidJid.getNullForInvalid(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() {

View File

@ -1,88 +1,363 @@
package eu.siacs.conversations.xmpp.jingle;
import android.annotation.SuppressLint;
import android.os.SystemClock;
import android.util.Base64;
import android.util.Log;
import java.math.BigInteger;
import com.google.common.base.Function;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableSet;
import org.checkerframework.checker.nullness.compatqual.NullableDecl;
import java.lang.ref.WeakReference;
import java.security.SecureRandom;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
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 eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.RtpSessionStatus;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.services.AbstractConnectionManager;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.OnIqPacketReceived;
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
import rocks.xmpp.addr.Jid;
public class JingleConnectionManager extends AbstractConnectionManager {
private List<JingleConnection> connections = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
private final HashMap<RtpSessionProposal, DeviceDiscoveryState> rtpSessionProposals = new HashMap<>();
private final Map<AbstractJingleConnection.Id, AbstractJingleConnection> connections = new ConcurrentHashMap<>();
private final Cache<PersistableSessionId, JingleRtpConnection.State> endedSessions = CacheBuilder.newBuilder()
.expireAfterWrite(30, TimeUnit.MINUTES)
.build();
private HashMap<Jid, JingleCandidate> primaryCandidates = new HashMap<>();
@SuppressLint("TrulyRandom")
private SecureRandom random = new SecureRandom();
public JingleConnectionManager(XmppConnectionService service) {
super(service);
}
public void deliverPacket(Account account, JinglePacket packet) {
if (packet.isAction("session-initiate")) {
JingleConnection connection = new JingleConnection(this);
connection.init(account, packet);
connections.add(connection);
static String nextRandomId() {
final byte[] id = new byte[16];
new SecureRandom().nextBytes(id);
return Base64.encodeToString(id, Base64.NO_WRAP | Base64.NO_PADDING);
}
public void deliverPacket(final Account account, final JinglePacket packet) {
final String sessionId = packet.getSessionId();
if (sessionId == null) {
respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
return;
}
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, 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 (FileTransferDescription.NAMESPACES.contains(descriptionNamespace)) {
connection = new JingleFileTransferConnection(this, id, from);
} else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && !usesTor(account)) {
final boolean sessionEnded = this.endedSessions.asMap().containsKey(PersistableSessionId.of(id));
final boolean stranger = isWithStrangerAndStrangerNotificationsAreOff(account, id.with);
if (isBusy() || sessionEnded || stranger) {
Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": rejected session with " + id.with + " because busy. sessionEnded=" + sessionEnded + ", stranger=" + stranger);
mXmppConnectionService.sendIqPacket(account, packet.generateResponse(IqPacket.TYPE.RESULT), null);
final JinglePacket sessionTermination = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
sessionTermination.setTo(id.with);
sessionTermination.setReason(Reason.BUSY, null);
mXmppConnectionService.sendIqPacket(account, sessionTermination, null);
return;
}
connection = new JingleRtpConnection(this, id, from);
} else {
respondWithJingleError(account, packet, "unsupported-info", "feature-not-implemented", "cancel");
return;
}
connections.put(id, connection);
connection.deliverPacket(packet);
} else {
for (JingleConnection connection : connections) {
if (connection.getAccount() == account
&& connection.getSessionId().equals(
packet.getSessionId())
&& connection.getCounterPart().equals(packet.getFrom())) {
connection.deliverPacket(packet);
Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
}
}
private boolean usesTor(final Account account) {
return account.isOnion() || mXmppConnectionService.useTorToConnect();
}
public boolean isBusy() {
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);
}
}
private boolean isWithStrangerAndStrangerNotificationsAreOff(final Account account, Jid with) {
final boolean notifyForStrangers = mXmppConnectionService.getNotificationService().notificationsFromStrangers();
if (notifyForStrangers) {
return false;
}
final Contact contact = account.getRoster().getContact(with);
return !contact.showInContactList();
}
ScheduledFuture<?> schedule(final Runnable runnable, final long delay, final TimeUnit timeUnit) {
return this.scheduledExecutorService.schedule(runnable, delay, timeUnit);
}
void respondWithJingleError(final Account account, final IqPacket original, String jingleCondition, String condition, String conditionType) {
final IqPacket response = original.generateResponse(IqPacket.TYPE.ERROR);
final Element error = response.addChild("error");
error.setAttribute("type", conditionType);
error.addChild(condition, "urn:ietf:params:xml:ns:xmpp-stanzas");
error.addChild(jingleCondition, "urn:xmpp:jingle:errors:1");
account.getXmppConnection().sendIqPacket(response, null);
}
public void deliverMessage(final Account account, final Jid to, final Jid from, final Element message, String serverMsgId, long timestamp) {
Preconditions.checkArgument(Namespace.JINGLE_MESSAGE.equals(message.getNamespace()));
final String sessionId = message.getAttribute("id");
if (sessionId == null) {
return;
}
if ("accept".equals(message.getName())) {
for (AbstractJingleConnection connection : connections.values()) {
if (connection instanceof JingleRtpConnection) {
final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
final AbstractJingleConnection.Id id = connection.getId();
if (id.account == account && id.sessionId.equals(sessionId)) {
rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
return;
}
}
}
return;
}
final boolean fromSelf = from.asBareJid().equals(account.getJid().asBareJid());
final AbstractJingleConnection.Id id;
if (fromSelf) {
if (to != null && to.isFullJid()) {
id = AbstractJingleConnection.Id.of(account, to, sessionId);
} else {
return;
}
} else {
id = AbstractJingleConnection.Id.of(account, from, sessionId);
}
final AbstractJingleConnection existingJingleConnection = connections.get(id);
if (existingJingleConnection != null) {
if (existingJingleConnection instanceof JingleRtpConnection) {
((JingleRtpConnection) existingJingleConnection).deliveryMessage(from, message, serverMsgId, timestamp);
} else {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + existingJingleConnection.getClass().getName() + " does not support jingle messages");
}
return;
}
if (fromSelf) {
if ("proceed".equals(message.getName())) {
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);
Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": updated previous busy because call got picked up by another device");
return;
}
}
Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
IqPacket response = packet.generateResponse(IqPacket.TYPE.ERROR);
Element error = response.addChild("error");
error.setAttribute("type", "cancel");
error.addChild("item-not-found",
"urn:ietf:params:xml:ns:xmpp-stanzas");
error.addChild("unknown-session", "urn:xmpp:jingle:errors:1");
account.getXmppConnection().sendIqPacket(response, null);
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignore jingle message from self");
return;
}
if ("propose".equals(message.getName())) {
final Propose propose = Propose.upgrade(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() && !usesTor(account)) {
final Collection<Media> media = Collections2.transform(rtpDescriptions, RtpDescription::getMedia);
if (media.contains(Media.UNKNOWN)) {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": encountered unknown media in session proposal. " + propose);
return;
}
final boolean stranger = isWithStrangerAndStrangerNotificationsAreOff(account, id.with);
if (isBusy() || stranger) {
writeLogMissedIncoming(account, id.with.asBareJid(), id.sessionId, serverMsgId, timestamp);
if (stranger) {
Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring call proposal from stranger " + id.with);
return;
}
final int activeDevices = account.countPresences();
Log.d(Config.LOGTAG, "active devices: " + activeDevices);
if (activeDevices == 0) {
final MessagePacket reject = mXmppConnectionService.getMessageGenerator().sessionReject(from, sessionId);
mXmppConnectionService.sendMessagePacket(account, reject);
} else {
Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring proposal because busy on this device but there are other devices");
}
} else {
final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, from);
this.connections.put(id, rtpConnection);
rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
}
} else {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to react to proposed session with " + rtpDescriptions.size() + " rtp descriptions of " + descriptions.size() + " total descriptions");
}
} else if ("proceed".equals(message.getName())) {
synchronized (rtpSessionProposals) {
final RtpSessionProposal proposal = getRtpSessionProposal(account, from.asBareJid(), sessionId);
if (proposal != null) {
rtpSessionProposals.remove(proposal);
final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, account.getJid());
rtpConnection.setProposedMedia(proposal.media);
this.connections.put(id, rtpConnection);
rtpConnection.transitionOrThrow(AbstractJingleConnection.State.PROPOSED);
rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
} else {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": no rtp session proposal found for " + from + " to deliver proceed");
}
}
} else if ("reject".equals(message.getName())) {
final RtpSessionProposal proposal = new RtpSessionProposal(account, from.asBareJid(), sessionId);
synchronized (rtpSessionProposals) {
if (rtpSessionProposals.remove(proposal) != null) {
writeLogMissedOutgoing(account, proposal.with, proposal.sessionId, serverMsgId, timestamp);
mXmppConnectionService.notifyJingleRtpConnectionUpdate(account, proposal.with, proposal.sessionId, RtpEndUserState.DECLINED_OR_BUSY);
} else {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": no rtp session proposal found for " + from + " to deliver reject");
}
}
} else {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retrieved out of order jingle message");
}
}
private RtpSessionProposal getRtpSessionProposal(final Account account, Jid from, String sessionId) {
for (RtpSessionProposal rtpSessionProposal : rtpSessionProposals.keySet()) {
if (rtpSessionProposal.sessionId.equals(sessionId) && rtpSessionProposal.with.equals(from) && rtpSessionProposal.account.getJid().equals(account.getJid())) {
return rtpSessionProposal;
}
}
return null;
}
private void writeLogMissedOutgoing(final Account account, Jid with, final String sessionId, String serverMsgId, long timestamp) {
final Conversation conversation = mXmppConnectionService.findOrCreateConversation(
account,
with.asBareJid(),
false,
false
);
final Message message = new Message(
conversation,
Message.STATUS_SEND,
Message.TYPE_RTP_SESSION,
sessionId
);
message.setBody(new RtpSessionStatus(false, 0).toString());
message.setServerMsgId(serverMsgId);
message.setTime(timestamp);
writeMessage(message);
}
private void writeLogMissedIncoming(final Account account, Jid with, final String sessionId, String serverMsgId, long timestamp) {
final Conversation conversation = mXmppConnectionService.findOrCreateConversation(
account,
with.asBareJid(),
false,
false
);
final Message message = new Message(
conversation,
Message.STATUS_RECEIVED,
Message.TYPE_RTP_SESSION,
sessionId
);
message.setBody(new RtpSessionStatus(false, 0).toString());
message.setServerMsgId(serverMsgId);
message.setTime(timestamp);
writeMessage(message);
}
private void writeMessage(final Message message) {
final Conversational conversational = message.getConversation();
if (conversational instanceof Conversation) {
((Conversation) conversational).add(message);
mXmppConnectionService.databaseBackend.createMessage(message);
mXmppConnectionService.updateConversationUi();
} else {
throw new IllegalStateException("Somehow the conversation in a message was a stub");
}
}
public JingleConnection createNewConnection(Message message) {
Transferable old = message.getTransferable();
public void startJingleFileTransfer(final Message message) {
Preconditions.checkArgument(message.isFileOrImage(), "Message is not of type file or image");
final Transferable old = message.getTransferable();
if (old != null) {
old.cancel();
}
JingleConnection connection = new JingleConnection(this);
final Account account = message.getConversation().getAccount();
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(message);
final JingleFileTransferConnection connection = new JingleFileTransferConnection(this, id, account.getJid());
mXmppConnectionService.markMessage(message, Message.STATUS_WAITING);
this.connections.put(id, connection);
connection.init(message);
this.connections.add(connection);
return connection;
}
public JingleConnection createNewConnection(final JinglePacket packet) {
JingleConnection connection = new JingleConnection(this);
this.connections.add(connection);
return connection;
void finishConnection(final AbstractJingleConnection connection) {
this.connections.remove(connection.getId());
}
public void finishConnection(JingleConnection connection) {
this.connections.remove(connection);
}
public void getPrimaryCandidate(final Account account, final boolean initiator, final OnPrimaryCandidateFound listener) {
void getPrimaryCandidate(final Account account, final boolean initiator, final OnPrimaryCandidateFound listener) {
if (Config.DISABLE_PROXY_LOOKUP) {
listener.onPrimaryCandidateFound(false, null);
return;
@ -97,7 +372,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
@Override
public void onIqPacketReceived(Account account, IqPacket packet) {
Element streamhost = packet.query().findChild("streamhost", Namespace.BYTE_STREAMS);
final Element streamhost = packet.query().findChild("streamhost", Namespace.BYTE_STREAMS);
final String host = streamhost == null ? null : streamhost.getAttribute("host");
final String port = streamhost == null ? null : streamhost.getAttribute("port");
if (host != null && port != null) {
@ -112,7 +387,6 @@ public class JingleConnectionManager extends AbstractConnectionManager {
listener.onPrimaryCandidateFound(true, candidate);
} catch (final NumberFormatException e) {
listener.onPrimaryCandidateFound(false, null);
return;
}
} else {
listener.onPrimaryCandidateFound(false, null);
@ -129,31 +403,85 @@ public class JingleConnectionManager extends AbstractConnectionManager {
}
}
public String nextRandomId() {
return new BigInteger(50, random).toString(32);
public void retractSessionProposal(final Account account, final Jid with) {
synchronized (this.rtpSessionProposals) {
RtpSessionProposal matchingProposal = null;
for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) {
if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
matchingProposal = proposal;
break;
}
}
if (matchingProposal != null) {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retracting rtp session proposal with " + with);
this.rtpSessionProposals.remove(matchingProposal);
final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionRetract(matchingProposal);
writeLogMissedOutgoing(account, matchingProposal.with, matchingProposal.sessionId, null, System.currentTimeMillis());
mXmppConnectionService.sendMessagePacket(account, messagePacket);
}
}
}
public void proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
synchronized (this.rtpSessionProposals) {
for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry : this.rtpSessionProposals.entrySet()) {
RtpSessionProposal proposal = entry.getKey();
if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
final DeviceDiscoveryState preexistingState = entry.getValue();
if (preexistingState != null && preexistingState != DeviceDiscoveryState.FAILED) {
mXmppConnectionService.notifyJingleRtpConnectionUpdate(
account,
with,
proposal.sessionId,
preexistingState.toEndUserState()
);
return;
}
}
}
if (isBusy()) {
throw new IllegalStateException("There is already a running RTP session. This should have been caught by the UI");
}
final RtpSessionProposal proposal = RtpSessionProposal.of(account, with.asBareJid(), media);
this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
mXmppConnectionService.notifyJingleRtpConnectionUpdate(
account,
proposal.with,
proposal.sessionId,
RtpEndUserState.FINDING_DEVICE
);
final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
Log.d(Config.LOGTAG, messagePacket.toString());
mXmppConnectionService.sendMessagePacket(account, messagePacket);
}
}
public void deliverIbbPacket(Account account, IqPacket packet) {
String sid = null;
Element payload = null;
if (packet.hasChild("open", "http://jabber.org/protocol/ibb")) {
payload = packet.findChild("open", "http://jabber.org/protocol/ibb");
final String sid;
final Element payload;
if (packet.hasChild("open", Namespace.IBB)) {
payload = packet.findChild("open", Namespace.IBB);
sid = payload.getAttribute("sid");
} else if (packet.hasChild("data", "http://jabber.org/protocol/ibb")) {
payload = packet.findChild("data", "http://jabber.org/protocol/ibb");
} else if (packet.hasChild("data", Namespace.IBB)) {
payload = packet.findChild("data", Namespace.IBB);
sid = payload.getAttribute("sid");
} else if (packet.hasChild("close", "http://jabber.org/protocol/ibb")) {
payload = packet.findChild("close", "http://jabber.org/protocol/ibb");
} else if (packet.hasChild("close", Namespace.IBB)) {
payload = packet.findChild("close", Namespace.IBB);
sid = payload.getAttribute("sid");
} else {
payload = null;
sid = null;
}
if (sid != null) {
for (JingleConnection connection : connections) {
if (connection.getAccount() == account
&& connection.hasTransportId(sid)) {
JingleTransport transport = connection.getTransport();
for (final AbstractJingleConnection connection : this.connections.values()) {
if (connection instanceof JingleFileTransferConnection) {
final JingleFileTransferConnection fileTransfer = (JingleFileTransferConnection) connection;
final JingleTransport transport = fileTransfer.getTransport();
if (transport instanceof JingleInBandTransport) {
JingleInBandTransport inbandTransport = (JingleInBandTransport) transport;
inbandTransport.deliverPayload(packet, payload);
final JingleInBandTransport inBandTransport = (JingleInBandTransport) transport;
if (inBandTransport.matches(account, sid)) {
inBandTransport.deliverPayload(packet, payload);
}
return;
}
}
@ -163,11 +491,157 @@ public class JingleConnectionManager extends AbstractConnectionManager {
account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
}
public void cancelInTransmission() {
for (JingleConnection connection : this.connections) {
if (connection.getJingleStatus() == JingleConnection.JINGLE_STATUS_TRANSMITTING) {
connection.abort("connectivity-error");
public void notifyRebound() {
for (final AbstractJingleConnection connection : this.connections.values()) {
connection.notifyRebound();
}
}
public WeakReference<JingleRtpConnection> findJingleRtpConnection(Account account, Jid with, String sessionId) {
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, Jid.ofEscaped(with), sessionId);
final AbstractJingleConnection connection = connections.get(id);
if (connection instanceof JingleRtpConnection) {
return new WeakReference<>((JingleRtpConnection) connection);
}
return null;
}
public void updateProposedSessionDiscovered(Account account, Jid from, String sessionId, final DeviceDiscoveryState target) {
synchronized (this.rtpSessionProposals) {
final RtpSessionProposal sessionProposal = getRtpSessionProposal(account, 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) {
Log.d(Config.LOGTAG, "session proposal already at discovered. not going to fall back");
return;
}
this.rtpSessionProposals.put(sessionProposal, target);
mXmppConnectionService.notifyJingleRtpConnectionUpdate(account, sessionProposal.with, sessionProposal.sessionId, target.toEndUserState());
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": 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) {
((JingleRtpConnection) connection).rejectCall();
}
}
}
}
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(Account account, final Jid with, String sessionId) {
final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with, sessionId);
final AbstractJingleConnection existingJingleConnection = connections.get(id);
if (existingJingleConnection instanceof JingleRtpConnection) {
((JingleRtpConnection) existingJingleConnection).deliverFailedProceed();
}
}
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 endSession(AbstractJingleConnection.Id id, final AbstractJingleConnection.State state) {
this.endedSessions.put(PersistableSessionId.of(id), state);
}
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 enum DeviceDiscoveryState {
SEARCHING, DISCOVERED, FAILED;
public RtpEndUserState toEndUserState() {
switch (this) {
case SEARCHING:
return RtpEndUserState.FINDING_DEVICE;
case DISCOVERED:
return RtpEndUserState.RINGING;
default:
return RtpEndUserState.CONNECTIVITY_ERROR;
}
}
}
public static class RtpSessionProposal {
public final Jid with;
public final String sessionId;
public final Set<Media> media;
private final Account account;
private RtpSessionProposal(Account account, Jid with, String sessionId) {
this(account, with, sessionId, Collections.emptySet());
}
private RtpSessionProposal(Account account, Jid with, String sessionId, Set<Media> media) {
this.account = account;
this.with = with;
this.sessionId = sessionId;
this.media = media;
}
public static RtpSessionProposal of(Account account, Jid with, Set<Media> media) {
return new RtpSessionProposal(account, with, nextRandomId(), media);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RtpSessionProposal proposal = (RtpSessionProposal) o;
return Objects.equal(account.getJid(), proposal.account.getJid()) &&
Objects.equal(with, proposal.with) &&
Objects.equal(sessionId, proposal.sessionId);
}
@Override
public int hashCode() {
return Objects.hashCode(account.getJid(), with, sessionId);
}
}
}

View File

@ -3,6 +3,8 @@ package eu.siacs.conversations.xmpp.jingle;
import android.util.Base64;
import android.util.Log;
import com.google.common.base.Preconditions;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@ -33,7 +35,7 @@ public class JingleInBandTransport extends JingleTransport {
private boolean connected = true;
private DownloadableFile file;
private final JingleConnection connection;
private final JingleFileTransferConnection connection;
private InputStream fileInputStream = null;
private InputStream innerInputStream = null;
@ -60,10 +62,10 @@ public class JingleInBandTransport extends JingleTransport {
}
};
JingleInBandTransport(final JingleConnection connection, final String sid, final int blockSize) {
JingleInBandTransport(final JingleFileTransferConnection connection, final String sid, final int blockSize) {
this.connection = connection;
this.account = connection.getAccount();
this.counterpart = connection.getCounterPart();
this.account = connection.getId().account;
this.counterpart = connection.getId().with;
this.blockSize = blockSize;
this.sessionId = sid;
}
@ -77,6 +79,10 @@ public class JingleInBandTransport extends JingleTransport {
this.account.getXmppConnection().sendIqPacket(iq, null);
}
public boolean matches(final Account account, final String sessionId) {
return this.account == account && this.sessionId.equals(sessionId);
}
public void connect(final OnTransportConnected callback) {
IqPacket iq = new IqPacket(IqPacket.TYPE.SET);
iq.setTo(this.counterpart);
@ -96,7 +102,7 @@ public class JingleInBandTransport extends JingleTransport {
@Override
public void receive(DownloadableFile file, OnFileTransmissionStatusChanged callback) {
this.onFileTransmissionStatusChanged = callback;
this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback);
this.file = file;
try {
this.digest = MessageDigest.getInstance("SHA-1");
@ -116,7 +122,7 @@ public class JingleInBandTransport extends JingleTransport {
@Override
public void send(DownloadableFile file, OnFileTransmissionStatusChanged callback) {
this.onFileTransmissionStatusChanged = callback;
this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback);
this.file = file;
try {
this.remainingSize = this.file.getExpectedSize();
@ -205,7 +211,7 @@ public class JingleInBandTransport extends JingleTransport {
connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100));
}
} catch (Exception e) {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage());
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage(), e);
FileBackend.close(fileOutputStream);
this.onFileTransmissionStatusChanged.onFileTransferAborted();
}

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,7 @@ import eu.siacs.conversations.services.AbstractConnectionManager;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.SocksSocketFactory;
import eu.siacs.conversations.utils.WakeLockHelper;
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
public class JingleSocks5Transport extends JingleTransport {
@ -31,8 +31,9 @@ public class JingleSocks5Transport extends JingleTransport {
private static final int SOCKET_TIMEOUT_PROXY = 5000;
private final JingleCandidate candidate;
private final JingleConnection connection;
private final JingleFileTransferConnection connection;
private final String destination;
private final Account account;
private OutputStream outputStream;
private InputStream inputStream;
private boolean isEstablished = false;
@ -40,7 +41,7 @@ public class JingleSocks5Transport extends JingleTransport {
private ServerSocket serverSocket;
private Socket socket;
JingleSocks5Transport(JingleConnection jingleConnection, JingleCandidate candidate) {
JingleSocks5Transport(JingleFileTransferConnection jingleConnection, JingleCandidate candidate) {
final MessageDigest messageDigest;
try {
messageDigest = MessageDigest.getInstance("SHA-1");
@ -49,19 +50,20 @@ public class JingleSocks5Transport extends JingleTransport {
}
this.candidate = candidate;
this.connection = jingleConnection;
this.account = jingleConnection.getId().account;
final StringBuilder destBuilder = new StringBuilder();
if (jingleConnection.getFtVersion() == Content.Version.FT_3) {
Log.d(Config.LOGTAG, this.connection.getAccount().getJid().asBareJid() + ": using session Id instead of transport Id for proxy destination");
destBuilder.append(jingleConnection.getSessionId());
if (this.connection.getFtVersion() == FileTransferDescription.Version.FT_3) {
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": using session Id instead of transport Id for proxy destination");
destBuilder.append(this.connection.getId().sessionId);
} else {
destBuilder.append(jingleConnection.getTransportId());
destBuilder.append(this.connection.getTransportId());
}
if (candidate.isOurs()) {
destBuilder.append(jingleConnection.getAccount().getJid());
destBuilder.append(jingleConnection.getCounterPart());
destBuilder.append(this.account.getJid());
destBuilder.append(this.connection.getId().with);
} else {
destBuilder.append(jingleConnection.getCounterPart());
destBuilder.append(jingleConnection.getAccount().getJid());
destBuilder.append(this.connection.getId().with);
destBuilder.append(this.account.getJid());
}
messageDigest.reset();
this.destination = CryptoHelper.bytesToHex(messageDigest.digest(destBuilder.toString().getBytes()));
@ -130,7 +132,7 @@ public class JingleSocks5Transport extends JingleTransport {
responseHeader = new byte[]{0x05, 0x00, 0x00, 0x03};
success = true;
} else {
Log.d(Config.LOGTAG,connection.getAccount().getJid().asBareJid()+": destination mismatch. received "+receivedDestination+" (expected "+this.destination+")");
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": destination mismatch. received " + receivedDestination + " (expected " + this.destination + ")");
responseHeader = new byte[]{0x05, 0x04, 0x00, 0x03};
success = false;
}
@ -141,7 +143,7 @@ public class JingleSocks5Transport extends JingleTransport {
outputStream.write(response.array());
outputStream.flush();
if (success) {
Log.d(Config.LOGTAG,connection.getAccount().getJid().asBareJid()+": successfully processed connection to candidate "+candidate.getHost()+":"+candidate.getPort());
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": successfully processed connection to candidate " + candidate.getHost() + ":" + candidate.getPort());
socket.setSoTimeout(0);
this.socket = socket;
this.inputStream = inputStream;
@ -160,7 +162,7 @@ public class JingleSocks5Transport extends JingleTransport {
new Thread(() -> {
final int timeout = candidate.getType() == JingleCandidate.TYPE_DIRECT ? SOCKET_TIMEOUT_DIRECT : SOCKET_TIMEOUT_PROXY;
try {
final boolean useTor = connection.getAccount().isOnion() || connection.getConnectionManager().getXmppConnectionService().useTorToConnect();
final boolean useTor = this.account.isOnion() || connection.getConnectionManager().getXmppConnectionService().useTorToConnect();
if (useTor) {
socket = SocksSocketFactory.createSocketOverTor(candidate.getHost(), candidate.getPort());
} else {
@ -185,7 +187,7 @@ public class JingleSocks5Transport extends JingleTransport {
public void send(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) {
new Thread(() -> {
InputStream fileInputStream = null;
final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_send_" + connection.getSessionId());
final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_send_" + connection.getId().sessionId);
long transmitted = 0;
try {
wakeLock.acquire();
@ -193,7 +195,7 @@ public class JingleSocks5Transport extends JingleTransport {
digest.reset();
fileInputStream = connection.getFileInputStream();
if (fileInputStream == null) {
Log.d(Config.LOGTAG, connection.getAccount().getJid().asBareJid() + ": could not create input stream");
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create input stream");
callback.onFileTransferAborted();
return;
}
@ -213,8 +215,8 @@ public class JingleSocks5Transport extends JingleTransport {
callback.onFileTransmitted(file);
}
} catch (Exception e) {
final Account account = connection.getAccount();
Log.d(Config.LOGTAG, account.getJid().asBareJid()+": failed sending file after "+transmitted+"/"+file.getExpectedSize()+" ("+ socket.getInetAddress()+":"+socket.getPort()+")", e);
final Account account = this.account;
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed sending file after " + transmitted + "/" + file.getExpectedSize() + " (" + socket.getInetAddress() + ":" + socket.getPort() + ")", e);
callback.onFileTransferAborted();
} finally {
FileBackend.close(fileInputStream);
@ -227,7 +229,7 @@ public class JingleSocks5Transport extends JingleTransport {
public void receive(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) {
new Thread(() -> {
OutputStream fileOutputStream = null;
final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_receive_" + connection.getSessionId());
final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_receive_" + connection.getId().sessionId);
try {
wakeLock.acquire();
MessageDigest digest = MessageDigest.getInstance("SHA-1");
@ -237,7 +239,7 @@ public class JingleSocks5Transport extends JingleTransport {
fileOutputStream = connection.getFileOutputStream();
if (fileOutputStream == null) {
callback.onFileTransferAborted();
Log.d(Config.LOGTAG, connection.getAccount().getJid().asBareJid() + ": could not create output stream");
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create output stream");
return;
}
double size = file.getExpectedSize();
@ -248,7 +250,7 @@ public class JingleSocks5Transport extends JingleTransport {
count = inputStream.read(buffer);
if (count == -1) {
callback.onFileTransferAborted();
Log.d(Config.LOGTAG, connection.getAccount().getJid().asBareJid() + ": file ended prematurely with " + remainingSize + " bytes remaining");
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": file ended prematurely with " + remainingSize + " bytes remaining");
return;
} else {
fileOutputStream.write(buffer, 0, count);
@ -262,7 +264,7 @@ public class JingleSocks5Transport extends JingleTransport {
file.setSha1Sum(digest.digest());
callback.onFileTransmitted(file);
} catch (Exception e) {
Log.d(Config.LOGTAG, connection.getAccount().getJid().asBareJid() + ": " + e.getMessage());
Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": " + e.getMessage());
callback.onFileTransferAborted();
} finally {
WakeLockHelper.release(wakeLock);

View File

@ -0,0 +1,20 @@
package eu.siacs.conversations.xmpp.jingle;
import java.util.Locale;
public enum Media {
VIDEO, AUDIO, UNKNOWN;
@Override
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;
}
}
}

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,58 @@
package eu.siacs.conversations.xmpp.jingle;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.Presences;
import eu.siacs.conversations.entities.ServiceDiscoveryResult;
import eu.siacs.conversations.xml.Namespace;
public class RtpCapability {
private static List<String> BASIC_RTP_REQUIREMENTS = Arrays.asList(
Namespace.JINGLE,
Namespace.JINGLE_TRANSPORT_ICE_UDP,
Namespace.JINGLE_APPS_RTP,
Namespace.JINGLE_APPS_DTLS
);
private static List<String> VIDEO_REQUIREMENTS = Arrays.asList(
Namespace.JINGLE_FEATURE_AUDIO,
Namespace.JINGLE_FEATURE_VIDEO
);
public static Capability check(final Presence presence) {
final ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult();
final List<String> features = disco == null ? Collections.emptyList() : disco.getFeatures();
if (features.containsAll(BASIC_RTP_REQUIREMENTS)) {
if (features.containsAll(VIDEO_REQUIREMENTS)) {
return Capability.VIDEO;
}
if (features.contains(Namespace.JINGLE_FEATURE_AUDIO)) {
return Capability.AUDIO;
}
}
return Capability.NONE;
}
public static Capability check(final Contact contact) {
final Presences presences = contact.getPresences();
Capability result = Capability.NONE;
for(Presence presence : presences.getPresences().values()) {
Capability capability = check(presence);
if (capability == Capability.VIDEO) {
result = capability;
} else if (capability == Capability.AUDIO && result == Capability.NONE) {
result = capability;
}
}
return result;
}
public enum Capability {
NONE, AUDIO, VIDEO
}
}

View File

@ -0,0 +1,172 @@
package eu.siacs.conversations.xmpp.jingle;
import android.util.Log;
import com.google.common.base.Function;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.checkerframework.checker.nullness.compatqual.NullableDecl;
import java.util.Map;
import java.util.Set;
import eu.siacs.conversations.Config;
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.Reason;
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
public class RtpContentMap {
public final Group group;
public final Map<String, DescriptionTransport> contents;
private RtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
this.group = group;
this.contents = contents;
}
public static RtpContentMap of(final JinglePacket jinglePacket) {
return new RtpContentMap(jinglePacket.getGroup(), DescriptionTransport.of(jinglePacket.getJingleContents()));
}
public static RtpContentMap of(final SessionDescription sessionDescription) {
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, 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 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()));
}
}
}
public void requireDTLSFingerprint() {
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()));
}
}
}
public 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 Content content = new Content(Content.Creator.INITIATOR, entry.getKey());
if (entry.getValue().description != null) {
content.addChild(entry.getValue().description);
}
content.addChild(entry.getValue().transport);
jinglePacket.addJingleContent(content);
}
return jinglePacket;
}
public 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(null, newTransportInfo)));
}
public static class DescriptionTransport {
public final RtpDescription description;
public final IceUdpTransportInfo transport;
public DescriptionTransport(final RtpDescription description, final IceUdpTransportInfo transport) {
this.description = description;
this.transport = transport;
}
public static DescriptionTransport of(final Content content) {
final GenericDescription description = content.getDescription();
final GenericTransportInfo transportInfo = content.getTransport();
final RtpDescription rtpDescription;
final IceUdpTransportInfo iceUdpTransportInfo;
if (description == null) {
rtpDescription = null;
} else if (description instanceof RtpDescription) {
rtpDescription = (RtpDescription) description;
} else {
Log.d(Config.LOGTAG, "description was " + description);
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(rtpDescription, iceUdpTransportInfo);
}
public static DescriptionTransport of(final SessionDescription sessionDescription, final SessionDescription.Media media) {
final RtpDescription rtpDescription = RtpDescription.of(media);
final IceUdpTransportInfo transportInfo = IceUdpTransportInfo.of(sessionDescription, media);
return new DescriptionTransport(rtpDescription, transportInfo);
}
public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
return ImmutableMap.copyOf(Maps.transformValues(contents, new Function<Content, DescriptionTransport>() {
@NullableDecl
@Override
public DescriptionTransport apply(@NullableDecl Content content) {
return 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);
}
}
}

View File

@ -0,0 +1,16 @@
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
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
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
}

View File

@ -0,0 +1,344 @@
package eu.siacs.conversations.xmpp.jingle;
import android.util.Log;
import android.util.Pair;
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 java.util.List;
import java.util.Locale;
import java.util.Map;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
public class SessionDescription {
public final static String LINE_DIVIDER = "\r\n";
private final static String HARDCODED_MEDIA_PROTOCOL = "UDP/TLS/RTP/SAVPF"; //probably only true for DTLS-SRTP aka when we have a fingerprint
private final static int HARDCODED_MEDIA_PORT = 9;
private final static String HARDCODED_ICE_OPTIONS = "trickle renomination";
private final static 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 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)) {
mediaAttributes.put("ice-ufrag", ufrag);
}
checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces");
if (!Strings.isNullOrEmpty(pwd)) {
mediaAttributes.put("ice-pwd", pwd);
}
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
if (fingerprint != null) {
mediaAttributes.put("fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
mediaAttributes.put("setup", fingerprint.getSetup());
}
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());
List<RtpDescription.Parameter> parameters = payloadType.getParameters();
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 (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : description.feedbackNegotiationTrrInts()) {
mediaAttributes.put("rtcp-fb", "* trr-int " + feedbackNegotiationTrrInt.getValue());
}
for (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);
}
for (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 (RtpDescription.Source source : description.getSources()) {
for (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);
//random additional attributes
mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
mediaAttributes.put("sendrecv", "");
if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP)) {
mediaAttributes.put("rtcp-mux", "");
}
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], "");
}
}
@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,42 @@
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

@ -1,5 +0,0 @@
package eu.siacs.conversations.xmpp.jingle;
public enum Transport {
SOCKS, IBB
}

View File

@ -0,0 +1,527 @@
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.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 org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
import org.webrtc.Camera1Enumerator;
import org.webrtc.Camera2Enumerator;
import org.webrtc.CameraEnumerationAndroid;
import org.webrtc.CameraEnumerator;
import org.webrtc.CameraVideoCapturer;
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.SurfaceTextureHelper;
import org.webrtc.VideoSource;
import org.webrtc.VideoTrack;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.services.AppRTCAudioManager;
public class WebRTCWrapper {
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
private static final int CAPTURING_RESOLUTION = 1920;
private static final int CAPTURING_MAX_FRAME_RATE = 30;
private final EventCallback eventCallback;
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 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(PeerConnection.PeerConnectionState newState) {
eventCallback.onConnectionChange(newState);
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState 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) {
eventCallback.onIceCandidate(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() + ")");
final List<VideoTrack> videoTracks = mediaStream.videoTracks;
if (videoTracks.size() > 0) {
remoteVideoTrack = videoTracks.get(0);
Log.d(Config.LOGTAG, "remote video track enabled?=" + remoteVideoTrack.enabled());
} else {
Log.d(Config.LOGTAG, "no remote video tracks found");
}
}
@Override
public void onRemoveStream(MediaStream mediaStream) {
}
@Override
public void onDataChannel(DataChannel dataChannel) {
}
@Override
public void 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 + ")");
}
@Override
public void onTrack(RtpTransceiver transceiver) {
Log.d(EXTENDED_LOGGING_TAG, "onTrack(mid=" + transceiver.getMid() + ",media=" + transceiver.getMediaType() + ")");
}
};
@Nullable
private PeerConnection peerConnection = null;
private AudioTrack localAudioTrack = null;
private AppRTCAudioManager appRTCAudioManager = null;
private Context context = null;
private EglBase eglBase = null;
private CapturerChoice capturerChoice;
public WebRTCWrapper(final EventCallback eventCallback) {
this.eventCallback = eventCallback;
}
public void setup(final Context context, final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference) {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
);
this.eglBase = EglBase.create();
this.context = context;
mainHandler.post(() -> {
appRTCAudioManager = AppRTCAudioManager.create(context, speakerPhonePreference);
appRTCAudioManager.start(audioManagerEvents);
eventCallback.onAudioDeviceChanged(appRTCAudioManager.getSelectedAudioDevice(), appRTCAudioManager.getAudioDevices());
});
}
public 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");
PeerConnectionFactory peerConnectionFactory = PeerConnectionFactory.builder()
.setVideoDecoderFactory(new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
.setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true))
.createPeerConnectionFactory();
final MediaStream stream = peerConnectionFactory.createLocalMediaStream("my-media-stream");
final Optional<CapturerChoice> optionalCapturerChoice = media.contains(Media.VIDEO) ? getVideoCapturer() : Optional.absent();
if (optionalCapturerChoice.isPresent()) {
this.capturerChoice = optionalCapturerChoice.get();
final CameraVideoCapturer capturer = this.capturerChoice.cameraVideoCapturer;
final VideoSource videoSource = peerConnectionFactory.createVideoSource(false);
SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("webrtc", eglBase.getEglBaseContext());
capturer.initialize(surfaceTextureHelper, requireContext(), videoSource.getCapturerObserver());
Log.d(Config.LOGTAG, String.format("start capturing at %dx%d@%d", capturerChoice.captureFormat.width, capturerChoice.captureFormat.height, capturerChoice.getFrameRate()));
capturer.startCapture(capturerChoice.captureFormat.width, capturerChoice.captureFormat.height, capturerChoice.getFrameRate());
this.localVideoTrack = peerConnectionFactory.createVideoTrack("my-video-track", videoSource);
stream.addTrack(this.localVideoTrack);
}
if (media.contains(Media.AUDIO)) {
//set up audio track
final AudioSource audioSource = peerConnectionFactory.createAudioSource(new MediaConstraints());
this.localAudioTrack = peerConnectionFactory.createAudioTrack("my-audio-track", audioSource);
stream.addTrack(this.localAudioTrack);
}
final PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers);
rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; //XEP-0176 doesn't support tcp
final PeerConnection peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, peerConnectionObserver);
if (peerConnection == null) {
throw new InitializationException("Unable to create PeerConnection");
}
peerConnection.addStream(stream);
peerConnection.setAudioPlayout(true);
peerConnection.setAudioRecording(true);
this.peerConnection = peerConnection;
}
public void close() {
final PeerConnection peerConnection = this.peerConnection;
final CapturerChoice capturerChoice = this.capturerChoice;
final AppRTCAudioManager audioManager = this.appRTCAudioManager;
final EglBase eglBase = this.eglBase;
if (peerConnection != null) {
peerConnection.dispose();
this.peerConnection = null;
}
if (audioManager != null) {
mainHandler.post(audioManager::stop);
}
this.localVideoTrack = null;
this.remoteVideoTrack = null;
if (capturerChoice != null) {
try {
capturerChoice.cameraVideoCapturer.stopCapture();
} catch (InterruptedException e) {
Log.e(Config.LOGTAG, "unable to stop capturing");
}
}
if (eglBase != null) {
eglBase.release();
this.eglBase = null;
}
}
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 isMicrophoneEnabled() {
final AudioTrack audioTrack = this.localAudioTrack;
if (audioTrack == null) {
throw new IllegalStateException("Local audio track does not exist (yet)");
}
return audioTrack.enabled();
}
void setMicrophoneEnabled(final boolean enabled) {
final AudioTrack audioTrack = this.localAudioTrack;
if (audioTrack == null) {
throw new IllegalStateException("Local audio track does not exist (yet)");
}
audioTrack.setEnabled(enabled);
}
public boolean isVideoEnabled() {
final VideoTrack videoTrack = this.localVideoTrack;
if (videoTrack == null) {
throw new IllegalStateException("Local video track does not exist");
}
return videoTrack.enabled();
}
public void setVideoEnabled(final boolean enabled) {
final VideoTrack videoTrack = this.localVideoTrack;
if (videoTrack == null) {
throw new IllegalStateException("Local video track does not exist");
}
videoTrack.setEnabled(enabled);
}
public ListenableFuture<SessionDescription> createOffer() {
return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
final SettableFuture<SessionDescription> future = SettableFuture.create();
peerConnection.createOffer(new CreateSdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
future.set(sessionDescription);
}
@Override
public void onCreateFailure(String s) {
Log.d(Config.LOGTAG, "create failure" + s);
future.setException(new IllegalStateException("Unable to create offer: " + s));
}
}, new MediaConstraints());
return future;
}, MoreExecutors.directExecutor());
}
public ListenableFuture<SessionDescription> createAnswer() {
return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
final SettableFuture<SessionDescription> future = SettableFuture.create();
peerConnection.createAnswer(new CreateSdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
future.set(sessionDescription);
}
@Override
public void onCreateFailure(String s) {
future.setException(new IllegalStateException("Unable to create answer: " + s));
}
}, new MediaConstraints());
return future;
}, MoreExecutors.directExecutor());
}
public ListenableFuture<Void> setLocalDescription(final SessionDescription sessionDescription) {
Log.d(EXTENDED_LOGGING_TAG, "setting local description:");
for (final String line : sessionDescription.description.split(eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
Log.d(EXTENDED_LOGGING_TAG, line);
}
return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
final SettableFuture<Void> future = SettableFuture.create();
peerConnection.setLocalDescription(new SetSdpObserver() {
@Override
public void onSetSuccess() {
future.set(null);
}
@Override
public void onSetFailure(String s) {
Log.d(Config.LOGTAG, "unable to set local " + s);
future.setException(new IllegalArgumentException("unable to set local session description: " + s));
}
}, sessionDescription);
return future;
}, MoreExecutors.directExecutor());
}
public ListenableFuture<Void> setRemoteDescription(final SessionDescription sessionDescription) {
Log.d(EXTENDED_LOGGING_TAG, "setting remote description:");
for (final String line : sessionDescription.description.split(eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
Log.d(EXTENDED_LOGGING_TAG, line);
}
return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
final SettableFuture<Void> future = SettableFuture.create();
peerConnection.setRemoteDescription(new SetSdpObserver() {
@Override
public void onSetSuccess() {
future.set(null);
}
@Override
public void onSetFailure(String s) {
future.setException(new IllegalArgumentException("unable to set remote session description: " + s));
}
}, sessionDescription);
return future;
}, MoreExecutors.directExecutor());
}
@Nonnull
private ListenableFuture<PeerConnection> getPeerConnectionFuture() {
final PeerConnection peerConnection = this.peerConnection;
if (peerConnection == null) {
return Futures.immediateFailedFuture(new IllegalStateException("initialize PeerConnection first"));
} else {
return Futures.immediateFuture(peerConnection);
}
}
public void addIceCandidate(IceCandidate iceCandidate) {
requirePeerConnection().addIceCandidate(iceCandidate);
}
private CameraEnumerator getCameraEnumerator() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return new Camera2Enumerator(requireContext());
} else {
return new Camera1Enumerator();
}
}
private Optional<CapturerChoice> getVideoCapturer() {
final CameraEnumerator enumerator = getCameraEnumerator();
final String[] deviceNames = enumerator.getDeviceNames();
for (final String deviceName : deviceNames) {
if (enumerator.isFrontFacing(deviceName)) {
return Optional.fromNullable(of(enumerator, deviceName));
}
}
if (deviceNames.length == 0) {
return Optional.absent();
} else {
return Optional.fromNullable(of(enumerator, deviceNames[0]));
}
}
@Nullable
private static CapturerChoice of(CameraEnumerator enumerator, final String deviceName) {
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 CapturerChoice(capturer, captureFormat);
}
}
return null;
}
public PeerConnection.PeerConnectionState getState() {
return requirePeerConnection().connectionState();
}
EglBase.Context getEglBaseContext() {
return this.eglBase.getEglBaseContext();
}
public Optional<VideoTrack> getLocalVideoTrack() {
return Optional.fromNullable(this.localVideoTrack);
}
public Optional<VideoTrack> getRemoteVideoTrack() {
return Optional.fromNullable(this.remoteVideoTrack);
}
private PeerConnection requirePeerConnection() {
final PeerConnection peerConnection = this.peerConnection;
if (peerConnection == null) {
throw new IllegalStateException("initialize PeerConnection first");
}
return peerConnection;
}
private Context requireContext() {
final Context context = this.context;
if (context == null) {
throw new IllegalStateException("call setup first");
}
return context;
}
public AppRTCAudioManager getAudioManager() {
return appRTCAudioManager;
}
public interface EventCallback {
void onIceCandidate(IceCandidate iceCandidate);
void onConnectionChange(PeerConnection.PeerConnectionState newState);
void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
}
private static abstract 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");
}
}
private static abstract class CreateSdpObserver implements SdpObserver {
@Override
public void onSetSuccess() {
throw new IllegalStateException("Not able to use CreateSdpObserver");
}
@Override
public void onSetFailure(String s) {
throw new IllegalStateException("Not able to use CreateSdpObserver");
}
}
public static class InitializationException extends Exception {
private InitializationException(String message) {
super(message);
}
}
private static class CapturerChoice {
private final CameraVideoCapturer cameraVideoCapturer;
private final CameraEnumerationAndroid.CaptureFormat captureFormat;
public CapturerChoice(CameraVideoCapturer cameraVideoCapturer, CameraEnumerationAndroid.CaptureFormat captureFormat) {
this.cameraVideoCapturer = cameraVideoCapturer;
this.captureFormat = captureFormat;
}
public int getFrameRate() {
return Math.max(captureFormat.framerate.min, Math.min(CAPTURING_MAX_FRAME_RATE, captureFormat.framerate.max));
}
}
}

View File

@ -1,130 +1,121 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import android.support.annotation.NonNull;
import com.google.common.base.Preconditions;
import java.util.Locale;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
public class Content extends Element {
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;
}
}
private String transportId;
public Content() {
super("content");
}
public Content(String creator, String name) {
super("content");
this.setAttribute("creator", creator);
this.setAttribute("senders", creator);
public Content(final Creator creator, final String name) {
super("content", Namespace.JINGLE);
this.setAttribute("creator", creator.toString());
this.setAttribute("name", name);
}
public Version getVersion() {
if (hasChild("description", Version.FT_3.namespace)) {
return Version.FT_3;
} else if (hasChild("description", Version.FT_4.namespace)) {
return Version.FT_4;
} else if (hasChild("description", Version.FT_5.namespace)) {
return Version.FT_5;
}
return null;
private Content() {
super("content", Namespace.JINGLE);
}
public void setTransportId(String sid) {
this.transportId = sid;
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 Element setFileOffer(DownloadableFile actualFile, boolean otr, Version version) {
Element description = this.addChild("description", version.namespace);
Element file;
if (version == Version.FT_3) {
Element offer = description.addChild("offer");
file = offer.addChild("file");
} else {
file = description.addChild("file");
}
file.addChild("size").setContent(Long.toString(actualFile.getExpectedSize()));
if (otr) {
file.addChild("name").setContent(actualFile.getName() + ".otr");
} else {
file.addChild("name").setContent(actualFile.getName());
}
return file;
public String getContentName() {
return this.getAttribute("name");
}
public Element getFileOffer(Version version) {
Element description = this.findChild("description", version.namespace);
public Creator getCreator() {
return Creator.of(getAttribute("creator"));
}
public Senders getSenders() {
return Senders.of(getAttribute("senders"));
}
public void setSenders(Senders senders) {
this.setAttribute("senders", senders.toString());
}
public GenericDescription getDescription() {
final Element description = this.findChild("description");
if (description == null) {
return null;
}
if (version == Version.FT_3) {
Element offer = description.findChild("offer");
if (offer == null) {
return null;
}
return offer.findChild("file");
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 description.findChild("file");
return GenericDescription.upgrade(description);
}
}
public void setFileOffer(Element fileOffer, Version version) {
Element description = this.addChild("description", version.namespace);
if (version == Version.FT_3) {
description.addChild("offer").addChild(fileOffer);
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 {
description.addChild(fileOffer);
return null;
}
}
public String getTransportId() {
if (hasSocks5Transport()) {
this.transportId = socks5transport().getAttribute("sid");
} else if (hasIbbTransport()) {
this.transportId = ibbTransport().getAttribute("sid");
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));
}
return this.transportId;
}
public Element socks5transport() {
Element transport = this.findChild("transport", Namespace.JINGLE_TRANSPORTS_S5B);
if (transport == null) {
transport = this.addChild("transport", Namespace.JINGLE_TRANSPORTS_S5B);
transport.setAttribute("sid", this.transportId);
@Override
@NonNull
public String toString() {
return super.toString().toLowerCase(Locale.ROOT);
}
return transport;
}
public Element ibbTransport() {
Element transport = this.findChild("transport", Namespace.JINGLE_TRANSPORTS_IBB);
if (transport == null) {
transport = this.addChild("transport", Namespace.JINGLE_TRANSPORTS_IBB);
transport.setAttribute("sid", this.transportId);
public enum Senders {
BOTH, INITIATOR, NONE, RESPONDER;
public static Senders of(final String value) {
return Senders.valueOf(value.toUpperCase(Locale.ROOT));
}
return transport;
}
public boolean hasSocks5Transport() {
return this.hasChild("transport", Namespace.JINGLE_TRANSPORTS_S5B);
}
public boolean hasIbbTransport() {
return this.hasChild("transport", Namespace.JINGLE_TRANSPORTS_IBB);
@Override
@NonNull
public String toString() {
return super.toString().toLowerCase(Locale.ROOT);
}
}
}

View File

@ -0,0 +1,89 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions;
import java.util.Arrays;
import java.util.List;
import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.xml.Element;
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 of(DownloadableFile file, Version version, XmppAxolotlMessage axolotlMessage) {
final FileTransferDescription description = new FileTransferDescription("description", version.getNamespace());
final Element fileElement;
if (version == Version.FT_3) {
Element offer = description.addChild("offer");
fileElement = offer.addChild("file");
} else {
fileElement = description.addChild("file");
}
fileElement.addChild("size").setContent(Long.toString(file.getExpectedSize()));
fileElement.addChild("name").setContent(file.getName());
if (axolotlMessage != null) {
fileElement.addChild(axolotlMessage.toElement());
}
return description;
}
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 eu.siacs.conversations.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 eu.siacs.conversations.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,64 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import java.util.Collection;
import java.util.List;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
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,46 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.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,292 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import android.util.Log;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
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 com.google.common.collect.Lists;
import org.checkerframework.checker.nullness.compatqual.NullableDecl;
import java.util.Collection;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
public class IceUdpTransportInfo extends GenericTransportInfo {
private 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);
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 Fingerprint getFingerprint() {
final Element fingerprint = this.findChild("fingerprint", Namespace.JINGLE_APPS_DTLS);
return fingerprint == null ? null : Fingerprint.upgrade(fingerprint);
}
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 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) {
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 foundation = segments[0];
final String component = segments[1];
final String transport = segments[2];
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 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("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 transport = this.getAttribute("protocol");
checkNotNullNoWhitespace(transport, "protocol");
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;
}
public String getHash() {
return this.getAttribute("hash");
}
public String getSetup() {
return this.getAttribute("setup");
}
}
}

View File

@ -1,115 +1,165 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import android.util.Base64;
import android.support.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 java.util.Map;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import rocks.xmpp.addr.Jid;
public class JinglePacket extends IqPacket {
Content content = null;
Reason reason = null;
Element checksum = null;
Element jingle = new Element("jingle");
@Override
public Element addChild(Element child) {
if ("jingle".equals(child.getName())) {
Element contentElement = child.findChild("content");
if (contentElement != null) {
this.content = new Content();
this.content.setChildren(contentElement.getChildren());
this.content.setAttributes(contentElement.getAttributes());
}
Element reasonElement = child.findChild("reason");
if (reasonElement != null) {
this.reason = new Reason();
this.reason.setChildren(reasonElement.getChildren());
this.reason.setAttributes(reasonElement.getAttributes());
}
this.checksum = child.findChild("checksum");
this.jingle.setAttributes(child.getAttributes());
}
return child;
}
private JinglePacket() {
super();
}
public JinglePacket setContent(Content content) {
this.content = content;
return this;
}
public JinglePacket(final Action action, final String sessionId) {
super(TYPE.SET);
final Element jingle = addChild("jingle", Namespace.JINGLE);
jingle.setAttribute("sid", sessionId);
jingle.setAttribute("action", action.toString());
}
public Content getJingleContent() {
if (this.content == null) {
this.content = new Content();
}
return this.content;
}
public static JinglePacket upgrade(final IqPacket iqPacket) {
Preconditions.checkArgument(iqPacket.hasChild("jingle", Namespace.JINGLE));
final JinglePacket jinglePacket = new JinglePacket();
jinglePacket.setAttributes(iqPacket.getAttributes());
jinglePacket.setChildren(iqPacket.getChildren());
return jinglePacket;
}
public JinglePacket setReason(Reason reason) {
this.reason = reason;
return this;
}
//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 Reason getReason() {
return this.reason;
}
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 Element getChecksum() {
return this.checksum;
}
public void addGroup(final Group group) {
this.addJingleChild(group);
}
private void build() {
this.children.clear();
this.jingle.clearChildren();
this.jingle.setAttribute("xmlns", "urn:xmpp:jingle:1");
if (this.content != null) {
jingle.addChild(this.content);
}
if (this.reason != null) {
jingle.addChild(this.reason);
}
if (this.checksum != null) {
jingle.addChild(checksum);
}
this.children.add(jingle);
this.setAttribute("type", "set");
}
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 String getSessionId() {
return this.jingle.getAttribute("sid");
}
public void addJingleContent(final Content content) { //take content interface
addJingleChild(content);
}
public void setSessionId(String sid) {
this.jingle.setAttribute("sid", sid);
}
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);
}
@Override
public String toString() {
this.build();
return super.toString();
}
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);
}
}
public void setAction(String action) {
this.jingle.setAttribute("action", action);
}
//RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise
public void setInitiator(final Jid initiator) {
Preconditions.checkArgument(initiator.isFullJid(), "initiator should be a full JID");
findChild("jingle", Namespace.JINGLE).setAttribute("initiator", initiator.toEscapedString());
}
public String getAction() {
return this.jingle.getAttribute("action");
}
//RECOMMENDED for session-accept, NOT RECOMMENDED otherwise
public void setResponder(Jid responder) {
Preconditions.checkArgument(responder.isFullJid(), "responder should be a full JID");
findChild("jingle", Namespace.JINGLE).setAttribute("responder", responder.toEscapedString());
}
public void setInitiator(final Jid initiator) {
this.jingle.setAttribute("initiator", initiator.toString());
}
public Element getJingleChild(final String name) {
final Element jingle = findChild("jingle", Namespace.JINGLE);
return jingle == null ? null : jingle.findChild(name);
}
public boolean isAction(String action) {
return action.equalsIgnoreCase(this.getAction());
}
public void addJingleChild(final Element child) {
final Element jingle = findChild("jingle", Namespace.JINGLE);
jingle.addChild(child);
}
public void addChecksum(byte[] sha1Sum, String namespace) {
this.checksum = new Element("checksum",namespace);
checksum.setAttribute("creator","initiator");
checksum.setAttribute("name","a-file-offer");
Element hash = checksum.addChild("file").addChild("hash","urn:xmpp:hashes:2");
hash.setAttribute("algo","sha-1").setContent(Base64.encodeToString(sha1Sum,Base64.NO_WRAP));
}
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,41 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import java.util.List;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
public class Propose extends Element {
private Propose() {
super("propose", Namespace.JINGLE_MESSAGE);
}
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();
}
public static Propose upgrade(final Element element) {
Preconditions.checkArgument("propose".equals(element.getName()));
Preconditions.checkArgument(Namespace.JINGLE_MESSAGE.equals(element.getNamespace()));
final Propose propose = new Propose();
propose.setAttributes(element.getAttributes());
propose.setChildren(element.getChildren());
return propose;
}
}

View File

@ -1,13 +1,54 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import eu.siacs.conversations.xml.Element;
import android.support.annotation.NonNull;
public class Reason extends Element {
private Reason(String name) {
super(name);
}
import com.google.common.base.CaseFormat;
public Reason() {
super("reason");
}
}
import eu.siacs.conversations.xmpp.jingle.RtpContentMap;
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;
}
}
}

View File

@ -0,0 +1,596 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import android.util.Pair;
import com.google.common.base.Preconditions;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
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 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) {
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 Pair<String, List<Parameter>> ofSdpString(final String sdp) {
final String[] pair = sdp.split(" ");
if (pair.length == 2) {
final String id = pair[0];
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.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();
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);
}
}
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,50 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions;
import java.util.Collection;
import java.util.List;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.jingle.JingleCandidate;
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

@ -31,6 +31,18 @@ abstract public class AbstractAcknowledgeableStanza extends AbstractStanza {
return null;
}
public String getErrorCondition() {
Element error = findChild("error");
if (error != null) {
for(Element element : error.getChildren()) {
if (!element.getName().equals("text")) {
return element.getName();
}
}
}
return null;
}
public boolean valid() {
return InvalidJid.isValid(getFrom()) && InvalidJid.isValid(getTo());
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Some files were not shown because too many files have changed in this diff Show More