diff --git a/.travis.yml b/.travis.yml
index 7ded45126..fb2c6866e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 091e36daf..3d5d92537 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 2f6d166a2..47613ed64 100644
--- a/README.md
+++ b/README.md
@@ -150,7 +150,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 +367,12 @@ Unfortunately we don‘t 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
diff --git a/build.gradle b/build.gradle
index 88267e831..428737f27 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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,16 @@ 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')
}
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 +95,8 @@ android {
defaultConfig {
minSdkVersion 16
targetSdkVersion 25
- versionCode 367
- versionName "2.7.1"
+ versionCode 377
+ versionName "2.8.0-rc.3"
archivesBaseName += "-$versionName"
applicationId "eu.sum7.conversations"
resValue "string", "applicationId", applicationId
@@ -99,6 +104,13 @@ android {
buildConfigField "String", "LOGTAG", "\"conver6ations\""
}
+ splits {
+ abi {
+ universalApk true
+ enable true
+ }
+ }
+
dataBinding {
enabled true
}
@@ -247,4 +259,16 @@ android {
exclude 'META-INF/BCKEY.DSA'
exclude 'META-INF/BCKEY.SF'
}
+
+ android.applicationVariants.all { variant ->
+ variant.outputs.each { output ->
+ def baseAbiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))
+ if (baseAbiVersionCode != null) {
+ output.versionCodeOverride = (100 * variant.versionCode) + baseAbiVersionCode
+ } else {
+ output.versionCodeOverride = (100 * variant.versionCode)
+ }
+ }
+
+ }
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 79bc0165e..852a0aba2 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/proguard-rules.pro b/proguard-rules.pro
index 5683316d4..67ae158a5 100644
--- a/proguard-rules.pro
+++ b/proguard-rules.pro
@@ -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
diff --git a/src/conversations/res/values-el/strings.xml b/src/conversations/res/values-el/strings.xml
index 00b3d199a..dd9e2de9f 100644
--- a/src/conversations/res/values-el/strings.xml
+++ b/src/conversations/res/values-el/strings.xml
@@ -5,4 +5,7 @@
Δημιουργία νέου λογαριασμούΈχετε ήδη λογαριασμό XMPP; Αυτό μπορεί να συμβαίνει αν ήδη χρησιμοποιείτε ένα άλλο πρόγραμμα XMPP ή έχετε χρησιμοποιήσει το Conversations παλιότερα. Αν όχι, μπορείτε να δημιουργήσετε ένα νέο λογαριασμό XMPP τώρα.\nΧρήσιμη πληροφορία: Κάποιοι πάροχοι e-mail παρέχουν επίσης και λογαριασμούς XMPP.Το XMPP είναι ένα δίκτυο άμεσης ανταλλαγής μηνυμάτων ανεξάρτητο παρόχου. Μπορείτε να χρησιμοποιήσετε αυτό το πρόγραμμα με όποιον διακομιστή XMPP επιθυμείτε.\nΓια διευκόλυνση πάντως μπορείτε να δημιουργήσετε έναν λογαριασμό στο chat.sum7.eu, έναν πάροχο ειδικά σχεδιασμένο για χρήση με το Conversations.
+ Έχετε προσκληθεί στο %1$s. Ένα όνομα χρήστη έχει ήδη επιλεγεί για εσάς. Θα σας καθοδηγήσουμε στη διαδικασία δημιουργίας λογαριασμού.\nΕπιλέγοντας τον %1$s ως πάροχο θα μπορείτε να επικοινωνείτε με χρήστες άλλων παρόχων δίνοντάς τους την πλήρη διεύθυνση XMPP σας.
+ Έχετε προσκληθεί στο %1$s. Ένα όνομα χρήστη έχει ήδη επιλεγεί για εσάς. Θα σας καθοδηγήσουμε στη διαδικασία δημιουργίας λογαριασμού.\nΘα μπορείτε να επικοινωνείτε με χρήστες άλλων παρόχων δίνοντάς τους την πλήρη διεύθυνση XMPP σας.
+ Η πρόσκλησή σας στον διακομιστή
diff --git a/src/conversations/res/values-fr/strings.xml b/src/conversations/res/values-fr/strings.xml
index 8b9bf1d2d..93271e735 100644
--- a/src/conversations/res/values-fr/strings.xml
+++ b/src/conversations/res/values-fr/strings.xml
@@ -3,9 +3,9 @@
Choisissez votre fournisseur XMPPUtiliser chat.sum7.euCréer un nouveau compte
- 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.
- 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.
- 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.
- 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.
+ 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.
+ 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.\nToutefois, pour votre commodité, nous avons facilité la création d’un compte sur chat.sum7.eu ; un fournisseur spécialement conçu pour Conversations.
+ Vous avez été invité à %1$s. Nous allons vous guider à travers le processus de création d’un compte.\nEn choisissant %1$s comme fournisseur, vous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète.
+ Vous avez été invité à %1$s. Un nom d’utilisateur a déjà été choisi pour vous. Nous allons vous guider à travers le processus de création d’un compte.\nVous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète.Votre invitation au serveur
diff --git a/src/conversations/res/values-nl/strings.xml b/src/conversations/res/values-nl/strings.xml
index 8026f5d32..c7f624fa7 100644
--- a/src/conversations/res/values-nl/strings.xml
+++ b/src/conversations/res/values-nl/strings.xml
@@ -4,5 +4,8 @@
chat.sum7.eu gebruikenNieuwe account registrerenHeb 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.
- 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.
+ 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 conversations.im¹; een provider speciaal geschikt voor Conversations.
+ 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.
+ 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.
+ Je server uitnodiging
diff --git a/src/conversations/res/values-uk/strings.xml b/src/conversations/res/values-uk/strings.xml
index 02aa6c853..35b4480fc 100644
--- a/src/conversations/res/values-uk/strings.xml
+++ b/src/conversations/res/values-uk/strings.xml
@@ -4,5 +4,8 @@
Скористатися chat.sum7.euСтворити новий обліковий записВже маєте обліковий запис XMPP? Можливо, користуєтеся іншою програмою XMPP або користувалися цією програмою раніше. Якщо ні, можете створити новий обліковий запис XMPP просто зараз.\nЗверніть увагу: Деякі постачальники електронної пошти водночас надають облікові записи XMPP.
- XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP сервером, який оберете.\nПроте, для зручності, ми спростили створення облікового запису на chat.sum7.eu — в постачальника, який спеціально налаштований на роботу з цією програмою.
+ XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP сервером, який оберете.\nПроте, для зручності, ми спростили створення облікового запису на conversations.im¹ — в постачальника, який спеціально налаштований на роботу з цією програмою.
+ Вас запросили до %1$s. Ми проведемо Вас крок за кроком, щоб створити обліковий запис.\nОбираючи %1$s в якості свого постачальника, Ви зможете спілкуватися з користувачами інших постачальників, повідомивши їм свою повну адресу XMPP.
+ Вас запросили до %1$s. Для Вас уже обрали ім\'я користувача. Ми проведемо Вас крок за кроком, щоб створити обліковий запис.\nВи зможете спілкуватися з користувачами інших постачальників, повідомивши їм свою повну адресу XMPP.
+ Ваше запрошення до сервера
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 347372013..c78d32a98 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -31,6 +31,10 @@
+
+
+
+
+
+ android:value="eu.siacs.conversations.services.ContactChooserTargetService" />
+
diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java
index ec90e294f..53d2d74b3 100644
--- a/src/main/java/eu/siacs/conversations/entities/Conversation.java
+++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java
@@ -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);
}
diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java
index e44d2a2c0..f266c18e3 100644
--- a/src/main/java/eu/siacs/conversations/entities/Message.java
+++ b/src/main/java/eu/siacs/conversations/entities/Message.java
@@ -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,
diff --git a/src/main/java/eu/siacs/conversations/entities/RtpSessionStatus.java b/src/main/java/eu/siacs/conversations/entities/RtpSessionStatus.java
new file mode 100644
index 000000000..8e360cb27
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/entities/RtpSessionStatus.java
@@ -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;
+ }
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java b/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java
index c4babe81c..d0bdb5632 100644
--- a/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java
+++ b/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java
@@ -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 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 values = field.getValues();
Collections.sort(values);
for (String value : values) {
diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
index a24a4ba0d..7a3ce765d 100644
--- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
+++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
@@ -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 getFeatures(Account account) {
+ final XmppConnection connection = account.getXmppConnection();
+ final ArrayList 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 getFeatures(Account account) {
- final XmppConnection connection = account.getXmppConnection();
- final ArrayList 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;
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java
index 56445e0d6..a0cb0ca1f 100644
--- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java
+++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java
@@ -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 doesn’t 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 doesn’t seem to support that.";
+ private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesn’t 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 doesn’t 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 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 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;
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java
index a57f0eec8..9cb6e7bbd 100644
--- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java
+++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java
@@ -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 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);
diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
index 8958278eb..1ff94c7c0 100644
--- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
+++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java
@@ -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);
}
diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java b/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java
new file mode 100644
index 000000000..cd8a19820
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/services/AppRTCAudioManager.java
@@ -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 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 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 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 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java b/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java
new file mode 100644
index 000000000..e5ea9be02
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java
@@ -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 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 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 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java b/src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java
new file mode 100644
index 000000000..8bdc65f2e
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/services/AppRTCProximitySensor.java
@@ -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());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java b/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java
index bb028f96c..7caf80da0 100644
--- a/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java
+++ b/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java
@@ -26,18 +26,18 @@
*/
package eu.siacs.conversations.services;
-import android.support.v7.app.AppCompatActivity ;
import android.app.Application;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
+import android.os.Handler;
import android.preference.PreferenceManager;
+import android.support.v7.app.AppCompatActivity;
import android.util.Base64;
import android.util.Log;
import android.util.SparseArray;
-import android.os.Handler;
import org.json.JSONArray;
import org.json.JSONException;
@@ -52,19 +52,24 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
-import java.security.NoSuchAlgorithmException;
-import java.security.cert.*;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
-import java.util.ArrayList;
-import java.util.logging.Level;
-import java.util.logging.Logger;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateExpiredException;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import java.util.regex.Pattern;
import javax.net.ssl.HostnameVerifier;
@@ -77,6 +82,7 @@ import javax.net.ssl.X509TrustManager;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.DomainHostnameVerifier;
import eu.siacs.conversations.entities.MTMDecision;
+import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.ui.MemorizingActivity;
/**
@@ -92,812 +98,809 @@ import eu.siacs.conversations.ui.MemorizingActivity;
public class MemorizingTrustManager {
- private static final Pattern PATTERN_IPV4 = Pattern.compile("\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
- private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
- private static final Pattern PATTERN_IPV6_6HEX4DEC = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
- private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z");
- private static final Pattern PATTERN_IPV6 = Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z");
-
- final static String DECISION_INTENT = "de.duenndns.ssl.DECISION";
- public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId";
- public final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert";
- final static String DECISION_INTENT_CHOICE = DECISION_INTENT + ".decisionChoice";
-
- private final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName());
- public final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId";
- private final static int NOTIFICATION_ID = 100509;
-
- final static String NO_TRUST_ANCHOR = "Trust anchor for certification path not found.";
-
- static String KEYSTORE_DIR = "KeyStore";
- static String KEYSTORE_FILE = "KeyStore.bks";
-
- Context master;
- AppCompatActivity foregroundAct;
- NotificationManager notificationManager;
- private static int decisionId = 0;
- private static SparseArray openDecisions = new SparseArray();
-
- Handler masterHandler;
- private File keyStoreFile;
- private KeyStore appKeyStore;
- private X509TrustManager defaultTrustManager;
- private X509TrustManager appTrustManager;
- private String poshCacheDir;
-
- /** Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager.
- *
- * You need to supply the application context. This has to be one of:
- * - Application
- * - Activity
- * - Service
- *
- * The context is used for file management, to display the dialog /
- * notification and for obtaining translated strings.
- *
- * @param m Context for the application.
- * @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate.
- */
- public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) {
- init(m);
- this.appTrustManager = getTrustManager(appKeyStore);
- this.defaultTrustManager = defaultTrustManager;
- }
-
- /** Creates an instance of the MemorizingTrustManager class using the system X509TrustManager.
- *
- * You need to supply the application context. This has to be one of:
- * - Application
- * - Activity
- * - Service
- *
- * The context is used for file management, to display the dialog /
- * notification and for obtaining translated strings.
- *
- * @param m Context for the application.
- */
- public MemorizingTrustManager(Context m) {
- init(m);
- this.appTrustManager = getTrustManager(appKeyStore);
- this.defaultTrustManager = getTrustManager(null);
- }
-
- void init(Context m) {
- master = m;
- masterHandler = new Handler(m.getMainLooper());
- notificationManager = (NotificationManager)master.getSystemService(Context.NOTIFICATION_SERVICE);
-
- Application app;
- if (m instanceof Application) {
- app = (Application)m;
- } else if (m instanceof Service) {
- app = ((Service)m).getApplication();
- } else if (m instanceof AppCompatActivity) {
- app = ((AppCompatActivity)m).getApplication();
- } else throw new ClassCastException("MemorizingTrustManager context must be either Activity or Service!");
-
- File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE);
- keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE);
-
- poshCacheDir = app.getCacheDir().getAbsolutePath()+"/posh_cache/";
-
- appKeyStore = loadAppKeyStore();
- }
-
-
- /**
- * Binds an Activity to the MTM for displaying the query dialog.
- *
- * This is useful if your connection is run from a service that is
- * triggered by user interaction -- in such cases the activity is
- * visible and the user tends to ignore the service notification.
- *
- * You should never have a hidden activity bound to MTM! Use this
- * function in onResume() and @see unbindDisplayActivity in onPause().
- *
- * @param act Activity to be bound
- */
- public void bindDisplayActivity(AppCompatActivity act) {
- foregroundAct = act;
- }
-
- /**
- * Removes an Activity from the MTM display stack.
- *
- * Always call this function when the Activity added with
- * {@link #bindDisplayActivity(AppCompatActivity)} is hidden.
- *
- * @param act Activity to be unbound
- */
- public void unbindDisplayActivity(AppCompatActivity act) {
- // do not remove if it was overridden by a different activity
- if (foregroundAct == act)
- foregroundAct = null;
- }
-
- /**
- * Changes the path for the KeyStore file.
- *
- * The actual filename relative to the app's directory will be
- * app_dirname/filename.
- *
- * @param dirname directory to store the KeyStore.
- * @param filename file name for the KeyStore.
- */
- public static void setKeyStoreFile(String dirname, String filename) {
- KEYSTORE_DIR = dirname;
- KEYSTORE_FILE = filename;
- }
-
- /**
- * Get a list of all certificate aliases stored in MTM.
- *
- * @return an {@link Enumeration} of all certificates
- */
- public Enumeration getCertificates() {
- try {
- return appKeyStore.aliases();
- } catch (KeyStoreException e) {
- // this should never happen, however...
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Get a certificate for a given alias.
- *
- * @param alias the certificate's alias as returned by {@link #getCertificates()}.
- *
- * @return the certificate associated with the alias or null if none found.
- */
- public Certificate getCertificate(String alias) {
- try {
- return appKeyStore.getCertificate(alias);
- } catch (KeyStoreException e) {
- // this should never happen, however...
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Removes the given certificate from MTMs key store.
- *
- *
- * WARNING: this does not immediately invalidate the certificate. It is
- * well possible that (a) data is transmitted over still existing connections or
- * (b) new connections are created using TLS renegotiation, without a new cert
- * check.
- *
- * @param alias the certificate's alias as returned by {@link #getCertificates()}.
- *
- * @throws KeyStoreException if the certificate could not be deleted.
- */
- public void deleteCertificate(String alias) throws KeyStoreException {
- appKeyStore.deleteEntry(alias);
- keyStoreUpdated();
- }
-
- /**
- * Creates a new hostname verifier supporting user interaction.
- *
- *
This method creates a new {@link HostnameVerifier} that is bound to
- * the given instance of {@link MemorizingTrustManager}, and leverages an
- * existing {@link HostnameVerifier}. The returned verifier performs the
- * following steps, returning as soon as one of them succeeds:
- *
- *
- *
Success, if the wrapped defaultVerifier accepts the certificate.
- *
Success, if the server certificate is stored in the keystore under the given hostname.
- *
Ask the user and return accordingly.
- *
Failure on exception.
- *
- *
- * @param defaultVerifier the {@link HostnameVerifier} that should perform the actual check
- * @return a new hostname verifier using the MTM's key store
- *
- * @throws IllegalArgumentException if the defaultVerifier parameter is null
- */
- public DomainHostnameVerifier wrapHostnameVerifier(final HostnameVerifier defaultVerifier, final boolean interactive) {
- if (defaultVerifier == null)
- throw new IllegalArgumentException("The default verifier may not be null");
-
- return new MemorizingHostnameVerifier(defaultVerifier, interactive);
- }
-
- X509TrustManager getTrustManager(KeyStore ks) {
- try {
- TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
- tmf.init(ks);
- for (TrustManager t : tmf.getTrustManagers()) {
- if (t instanceof X509TrustManager) {
- return (X509TrustManager)t;
- }
- }
- } catch (Exception e) {
- // Here, we are covering up errors. It might be more useful
- // however to throw them out of the constructor so the
- // embedding app knows something went wrong.
- LOGGER.log(Level.SEVERE, "getTrustManager(" + ks + ")", e);
- }
- return null;
- }
-
- KeyStore loadAppKeyStore() {
- KeyStore ks;
- try {
- ks = KeyStore.getInstance(KeyStore.getDefaultType());
- } catch (KeyStoreException e) {
- LOGGER.log(Level.SEVERE, "getAppKeyStore()", e);
- return null;
- }
- try {
- ks.load(null, null);
- ks.load(new java.io.FileInputStream(keyStoreFile), "MTM".toCharArray());
- } catch (java.io.FileNotFoundException e) {
- LOGGER.log(Level.INFO, "getAppKeyStore(" + keyStoreFile + ") - file does not exist");
- } catch (Exception e) {
- LOGGER.log(Level.SEVERE, "getAppKeyStore(" + keyStoreFile + ")", e);
- }
- return ks;
- }
-
- void storeCert(String alias, Certificate cert) {
- try {
- appKeyStore.setCertificateEntry(alias, cert);
- } catch (KeyStoreException e) {
- LOGGER.log(Level.SEVERE, "storeCert(" + cert + ")", e);
- return;
- }
- keyStoreUpdated();
- }
-
- void storeCert(X509Certificate cert) {
- storeCert(cert.getSubjectDN().toString(), cert);
- }
-
- void keyStoreUpdated() {
- // reload appTrustManager
- appTrustManager = getTrustManager(appKeyStore);
-
- // store KeyStore to file
- java.io.FileOutputStream fos = null;
- try {
- fos = new java.io.FileOutputStream(keyStoreFile);
- appKeyStore.store(fos, "MTM".toCharArray());
- } catch (Exception e) {
- LOGGER.log(Level.SEVERE, "storeCert(" + keyStoreFile + ")", e);
- } finally {
- if (fos != null) {
- try {
- fos.close();
- } catch (IOException e) {
- LOGGER.log(Level.SEVERE, "storeCert(" + keyStoreFile + ")", e);
- }
- }
- }
- }
-
- // if the certificate is stored in the app key store, it is considered "known"
- private boolean isCertKnown(X509Certificate cert) {
- try {
- return appKeyStore.getCertificateAlias(cert) != null;
- } catch (KeyStoreException e) {
- return false;
- }
- }
-
- private boolean isExpiredException(Throwable e) {
- do {
- if (e instanceof CertificateExpiredException)
- return true;
- e = e.getCause();
- } while (e != null);
- return false;
- }
-
- public void checkCertTrusted(X509Certificate[] chain, String authType, String domain, boolean isServer, boolean interactive)
- throws CertificateException
- {
- LOGGER.log(Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")");
- try {
- LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager");
- if (isServer)
- appTrustManager.checkServerTrusted(chain, authType);
- else
- appTrustManager.checkClientTrusted(chain, authType);
- } catch (CertificateException ae) {
- LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae);
- // if the cert is stored in our appTrustManager, we ignore expiredness
- if (isExpiredException(ae)) {
- LOGGER.log(Level.INFO, "checkCertTrusted: accepting expired certificate from keystore");
- return;
- }
- if (isCertKnown(chain[0])) {
- LOGGER.log(Level.INFO, "checkCertTrusted: accepting cert already stored in keystore");
- return;
- }
- try {
- if (defaultTrustManager == null)
- throw ae;
- LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager");
- if (isServer)
- defaultTrustManager.checkServerTrusted(chain, authType);
- else
- defaultTrustManager.checkClientTrusted(chain, authType);
- } catch (CertificateException e) {
- boolean trustSystemCAs = !PreferenceManager.getDefaultSharedPreferences(master).getBoolean("dont_trust_system_cas", false);
- if (domain != null && isServer && trustSystemCAs && !isIp(domain)) {
- final String hash = getBase64Hash(chain[0],"SHA-256");
- final List fingerprints = getPoshFingerprints(domain);
- if (hash != null && fingerprints.size() > 0) {
- if (fingerprints.contains(hash)) {
- Log.d("mtm","trusted cert fingerprint of "+domain+" via posh");
- return;
- }
- if (getPoshCacheFile(domain).delete()) {
- Log.d("mtm", "deleted posh file for "+domain+" after not being able to verify");
- }
- }
- }
- if (interactive) {
- interactCert(chain, authType, e);
- } else {
- throw e;
- }
- }
- }
- }
-
- private List getPoshFingerprints(String domain) {
- List cached = getPoshFingerprintsFromCache(domain);
- if (cached == null) {
- return getPoshFingerprintsFromServer(domain);
- } else {
- return cached;
- }
- }
-
- private List getPoshFingerprintsFromServer(String domain) {
- return getPoshFingerprintsFromServer(domain, "https://"+domain+"/.well-known/posh/xmpp-client.json",-1,true);
- }
-
- private List getPoshFingerprintsFromServer(String domain, String url, int maxTtl, boolean followUrl) {
- Log.d("mtm","downloading json for "+domain+" from "+url);
- try {
- List results = new ArrayList<>();
- HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
- connection.setConnectTimeout(5000);
- connection.setReadTimeout(5000);
- BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
- String inputLine;
- StringBuilder builder = new StringBuilder();
- while ((inputLine = in.readLine()) != null) {
- builder.append(inputLine);
- }
- JSONObject jsonObject = new JSONObject(builder.toString());
- in.close();
- int expires = jsonObject.getInt("expires");
- if (expires <= 0) {
- return new ArrayList<>();
- }
- if (maxTtl >= 0) {
- expires = Math.min(maxTtl,expires);
- }
- String redirect;
- try {
- redirect = jsonObject.getString("url");
- } catch (JSONException e) {
- redirect = null;
- }
- if (followUrl && redirect != null && redirect.toLowerCase().startsWith("https")) {
- return getPoshFingerprintsFromServer(domain, redirect, expires, false);
- }
- JSONArray fingerprints = jsonObject.getJSONArray("fingerprints");
- for(int i = 0; i < fingerprints.length(); i++) {
- JSONObject fingerprint = fingerprints.getJSONObject(i);
- String sha256 = fingerprint.getString("sha-256");
- if (sha256 != null) {
- results.add(sha256);
- }
- }
- writeFingerprintsToCache(domain, results,1000L * expires+System.currentTimeMillis());
- return results;
- } catch (Exception e) {
- Log.d("mtm","error fetching posh "+e.getMessage());
- return new ArrayList<>();
- }
- }
-
- private File getPoshCacheFile(String domain) {
- return new File(poshCacheDir+domain+".json");
- }
-
- private void writeFingerprintsToCache(String domain, List results, long expires) {
- File file = getPoshCacheFile(domain);
- file.getParentFile().mkdirs();
- try {
- file.createNewFile();
- JSONObject jsonObject = new JSONObject();
- jsonObject.put("expires",expires);
- jsonObject.put("fingerprints",new JSONArray(results));
- FileOutputStream outputStream = new FileOutputStream(file);
- outputStream.write(jsonObject.toString().getBytes());
- outputStream.flush();
- outputStream.close();
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
-
- private List getPoshFingerprintsFromCache(String domain) {
- File file = getPoshCacheFile(domain);
- try {
- InputStream is = new FileInputStream(file);
- BufferedReader buf = new BufferedReader(new InputStreamReader(is));
-
- String line = buf.readLine();
- StringBuilder sb = new StringBuilder();
-
- while(line != null){
- sb.append(line).append("\n");
- line = buf.readLine();
- }
- JSONObject jsonObject = new JSONObject(sb.toString());
- is.close();
- long expires = jsonObject.getLong("expires");
- long expiresIn = expires - System.currentTimeMillis();
- if (expiresIn < 0) {
- file.delete();
- return null;
- } else {
- Log.d("mtm","posh fingerprints expire in "+(expiresIn/1000)+"s");
- }
- List result = new ArrayList<>();
- JSONArray jsonArray = jsonObject.getJSONArray("fingerprints");
- for(int i = 0; i < jsonArray.length(); ++i) {
- result.add(jsonArray.getString(i));
- }
- return result;
- } catch (FileNotFoundException e) {
- return null;
- } catch (IOException e) {
- return null;
- } catch (JSONException e) {
- file.delete();
- return null;
- }
- }
-
- private static boolean isIp(final String server) {
- return server != null && (
- PATTERN_IPV4.matcher(server).matches()
- || PATTERN_IPV6.matcher(server).matches()
- || PATTERN_IPV6_6HEX4DEC.matcher(server).matches()
- || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches()
- || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches());
- }
-
- private static String getBase64Hash(X509Certificate certificate, String digest) throws CertificateEncodingException {
- MessageDigest md;
- try {
- md = MessageDigest.getInstance(digest);
- } catch (NoSuchAlgorithmException e) {
- return null;
- }
- md.update(certificate.getEncoded());
- return Base64.encodeToString(md.digest(),Base64.NO_WRAP);
- }
-
- private X509Certificate[] getAcceptedIssuers() {
- LOGGER.log(Level.FINE, "getAcceptedIssuers()");
- return defaultTrustManager.getAcceptedIssuers();
- }
-
- private int createDecisionId(MTMDecision d) {
- int myId;
- synchronized(openDecisions) {
- myId = decisionId;
- openDecisions.put(myId, d);
- decisionId += 1;
- }
- return myId;
- }
-
- private static String hexString(byte[] data) {
- StringBuffer si = new StringBuffer();
- for (int i = 0; i < data.length; i++) {
- si.append(String.format("%02x", data[i]));
- if (i < data.length - 1)
- si.append(":");
- }
- return si.toString();
- }
-
- private static String certHash(final X509Certificate cert, String digest) {
- try {
- MessageDigest md = MessageDigest.getInstance(digest);
- md.update(cert.getEncoded());
- return hexString(md.digest());
- } catch (java.security.cert.CertificateEncodingException e) {
- return e.getMessage();
- } catch (java.security.NoSuchAlgorithmException e) {
- return e.getMessage();
- }
- }
-
- private void certDetails(StringBuffer si, X509Certificate c) {
- SimpleDateFormat validityDateFormater = new SimpleDateFormat("yyyy-MM-dd");
- si.append("\n");
- si.append(c.getSubjectDN().toString());
- si.append("\n");
- si.append(validityDateFormater.format(c.getNotBefore()));
- si.append(" - ");
- si.append(validityDateFormater.format(c.getNotAfter()));
- si.append("\nSHA-256: ");
- si.append(certHash(c, "SHA-256"));
- si.append("\nSHA-1: ");
- si.append(certHash(c, "SHA-1"));
- si.append("\nSigned by: ");
- si.append(c.getIssuerDN().toString());
- si.append("\n");
- }
-
- private String certChainMessage(final X509Certificate[] chain, CertificateException cause) {
- Throwable e = cause;
- LOGGER.log(Level.FINE, "certChainMessage for " + e);
- StringBuffer si = new StringBuffer();
- if (e.getCause() != null) {
- e = e.getCause();
- // HACK: there is no sane way to check if the error is a "trust anchor
- // not found", so we use string comparison.
- if (NO_TRUST_ANCHOR.equals(e.getMessage())) {
- si.append(master.getString(R.string.mtm_trust_anchor));
- } else
- si.append(e.getLocalizedMessage());
- si.append("\n");
- }
- si.append("\n");
- si.append(master.getString(R.string.mtm_connect_anyway));
- si.append("\n\n");
- si.append(master.getString(R.string.mtm_cert_details));
- for (X509Certificate c : chain) {
- certDetails(si, c);
- }
- return si.toString();
- }
-
- private String hostNameMessage(X509Certificate cert, String hostname) {
- StringBuffer si = new StringBuffer();
-
- si.append(master.getString(R.string.mtm_hostname_mismatch, hostname));
- si.append("\n\n");
- try {
- Collection> sans = cert.getSubjectAlternativeNames();
- if (sans == null) {
- si.append(cert.getSubjectDN());
- si.append("\n");
- } else for (List> altName : sans) {
- Object name = altName.get(1);
- if (name instanceof String) {
- si.append("[");
- si.append((Integer)altName.get(0));
- si.append("] ");
- si.append(name);
- si.append("\n");
- }
- }
- } catch (CertificateParsingException e) {
- e.printStackTrace();
- si.append("\n");
- }
- si.append("\n");
- si.append(master.getString(R.string.mtm_connect_anyway));
- si.append("\n\n");
- si.append(master.getString(R.string.mtm_cert_details));
- certDetails(si, cert);
- return si.toString();
- }
- /**
- * Returns the top-most entry of the activity stack.
- *
- * @return the Context of the currently bound UI or the master context if none is bound
- */
- Context getUI() {
- return (foregroundAct != null) ? foregroundAct : master;
- }
-
- int interact(final String message, final int titleId) {
- /* prepare the MTMDecision blocker object */
- MTMDecision choice = new MTMDecision();
- final int myId = createDecisionId(choice);
-
- masterHandler.post(new Runnable() {
- public void run() {
- Intent ni = new Intent(master, MemorizingActivity.class);
- ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId));
- ni.putExtra(DECISION_INTENT_ID, myId);
- ni.putExtra(DECISION_INTENT_CERT, message);
- ni.putExtra(DECISION_TITLE_ID, titleId);
-
- // we try to directly start the activity and fall back to
- // making a notification
- try {
- getUI().startActivity(ni);
- } catch (Exception e) {
- LOGGER.log(Level.FINE, "startActivity(MemorizingActivity)", e);
- }
- }
- });
-
- LOGGER.log(Level.FINE, "openDecisions: " + openDecisions + ", waiting on " + myId);
- try {
- synchronized(choice) { choice.wait(); }
- } catch (InterruptedException e) {
- LOGGER.log(Level.FINER, "InterruptedException", e);
- }
- LOGGER.log(Level.FINE, "finished wait on " + myId + ": " + choice.state);
- return choice.state;
- }
-
- void interactCert(final X509Certificate[] chain, String authType, CertificateException cause)
- throws CertificateException
- {
- switch (interact(certChainMessage(chain, cause), R.string.mtm_accept_cert)) {
- case MTMDecision.DECISION_ALWAYS:
- storeCert(chain[0]); // only store the server cert, not the whole chain
- case MTMDecision.DECISION_ONCE:
- break;
- default:
- throw (cause);
- }
- }
-
- boolean interactHostname(X509Certificate cert, String hostname)
- {
- switch (interact(hostNameMessage(cert, hostname), R.string.mtm_accept_servername)) {
- case MTMDecision.DECISION_ALWAYS:
- storeCert(hostname, cert);
- case MTMDecision.DECISION_ONCE:
- return true;
- default:
- return false;
- }
- }
-
- public static void interactResult(int decisionId, int choice) {
- MTMDecision d;
- synchronized(openDecisions) {
- d = openDecisions.get(decisionId);
- openDecisions.remove(decisionId);
- }
- if (d == null) {
- LOGGER.log(Level.SEVERE, "interactResult: aborting due to stale decision reference!");
- return;
- }
- synchronized(d) {
- d.state = choice;
- d.notify();
- }
- }
-
- class MemorizingHostnameVerifier implements DomainHostnameVerifier {
- private final HostnameVerifier defaultVerifier;
- private final boolean interactive;
-
- public MemorizingHostnameVerifier(HostnameVerifier wrapped, boolean interactive) {
- this.defaultVerifier = wrapped;
- this.interactive = interactive;
- }
-
- @Override
- public boolean verify(String domain, String hostname, SSLSession session) {
- LOGGER.log(Level.FINE, "hostname verifier for " + domain + ", trying default verifier first");
- // if the default verifier accepts the hostname, we are done
- if (defaultVerifier instanceof DomainHostnameVerifier) {
- if (((DomainHostnameVerifier) defaultVerifier).verify(domain,hostname, session)) {
- return true;
- }
- } else {
- if (defaultVerifier.verify(domain, session)) {
- return true;
- }
- }
-
-
- // otherwise, we check if the hostname is an alias for this cert in our keystore
- try {
- X509Certificate cert = (X509Certificate)session.getPeerCertificates()[0];
- //Log.d(TAG, "cert: " + cert);
- if (cert.equals(appKeyStore.getCertificate(domain.toLowerCase(Locale.US)))) {
- LOGGER.log(Level.FINE, "certificate for " + domain + " is in our keystore. accepting.");
- return true;
- } else {
- LOGGER.log(Level.FINE, "server " + domain + " provided wrong certificate, asking user.");
- if (interactive) {
- return interactHostname(cert, domain);
- } else {
- return false;
- }
- }
- } catch (Exception e) {
- e.printStackTrace();
- return false;
- }
- }
-
- @Override
- public boolean verify(String domain, SSLSession sslSession) {
- return verify(domain,null,sslSession);
- }
- }
-
-
- public X509TrustManager getNonInteractive(String domain) {
- return new NonInteractiveMemorizingTrustManager(domain);
- }
-
- public X509TrustManager getInteractive(String domain) {
- return new InteractiveMemorizingTrustManager(domain);
- }
-
- public X509TrustManager getNonInteractive() {
- return new NonInteractiveMemorizingTrustManager(null);
- }
-
- public X509TrustManager getInteractive() {
- return new InteractiveMemorizingTrustManager(null);
- }
-
- private class NonInteractiveMemorizingTrustManager implements X509TrustManager {
-
- private final String domain;
-
- public NonInteractiveMemorizingTrustManager(String domain) {
- this.domain = domain;
- }
-
- @Override
- public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
- MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, false);
- }
-
- @Override
- public void checkServerTrusted(X509Certificate[] chain, String authType)
- throws CertificateException {
- MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, true, false);
- }
-
- @Override
- public X509Certificate[] getAcceptedIssuers() {
- return MemorizingTrustManager.this.getAcceptedIssuers();
- }
-
- }
-
- private class InteractiveMemorizingTrustManager implements X509TrustManager {
- private final String domain;
-
- public InteractiveMemorizingTrustManager(String domain) {
- this.domain = domain;
- }
-
- @Override
- public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
- MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, true);
- }
-
- @Override
- public void checkServerTrusted(X509Certificate[] chain, String authType)
- throws CertificateException {
- MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, true, true);
- }
-
- @Override
- public X509Certificate[] getAcceptedIssuers() {
- return MemorizingTrustManager.this.getAcceptedIssuers();
- }
- }
+ final static String DECISION_INTENT = "de.duenndns.ssl.DECISION";
+ public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId";
+ public final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert";
+ public final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId";
+ final static String DECISION_INTENT_CHOICE = DECISION_INTENT + ".decisionChoice";
+ final static String NO_TRUST_ANCHOR = "Trust anchor for certification path not found.";
+ private static final Pattern PATTERN_IPV4 = Pattern.compile("\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+ private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+ private static final Pattern PATTERN_IPV6_6HEX4DEC = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+ private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z");
+ private static final Pattern PATTERN_IPV6 = Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z");
+ private final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName());
+ private final static int NOTIFICATION_ID = 100509;
+ static String KEYSTORE_DIR = "KeyStore";
+ static String KEYSTORE_FILE = "KeyStore.bks";
+ private static int decisionId = 0;
+ private static SparseArray openDecisions = new SparseArray();
+ Context master;
+ AppCompatActivity foregroundAct;
+ NotificationManager notificationManager;
+ Handler masterHandler;
+ private File keyStoreFile;
+ private KeyStore appKeyStore;
+ private X509TrustManager defaultTrustManager;
+ private X509TrustManager appTrustManager;
+ private String poshCacheDir;
+
+ /**
+ * Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager.
+ *
+ * You need to supply the application context. This has to be one of:
+ * - Application
+ * - Activity
+ * - Service
+ *
+ * The context is used for file management, to display the dialog /
+ * notification and for obtaining translated strings.
+ *
+ * @param m Context for the application.
+ * @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate.
+ */
+ public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) {
+ init(m);
+ this.appTrustManager = getTrustManager(appKeyStore);
+ this.defaultTrustManager = defaultTrustManager;
+ }
+
+ /**
+ * Creates an instance of the MemorizingTrustManager class using the system X509TrustManager.
+ *
+ * You need to supply the application context. This has to be one of:
+ * - Application
+ * - Activity
+ * - Service
+ *
+ * The context is used for file management, to display the dialog /
+ * notification and for obtaining translated strings.
+ *
+ * @param m Context for the application.
+ */
+ public MemorizingTrustManager(Context m) {
+ init(m);
+ this.appTrustManager = getTrustManager(appKeyStore);
+ this.defaultTrustManager = getTrustManager(null);
+ }
+
+ /**
+ * Changes the path for the KeyStore file.
+ *
+ * The actual filename relative to the app's directory will be
+ * app_dirname/filename.
+ *
+ * @param dirname directory to store the KeyStore.
+ * @param filename file name for the KeyStore.
+ */
+ public static void setKeyStoreFile(String dirname, String filename) {
+ KEYSTORE_DIR = dirname;
+ KEYSTORE_FILE = filename;
+ }
+
+ private static boolean isIp(final String server) {
+ return server != null && (
+ PATTERN_IPV4.matcher(server).matches()
+ || PATTERN_IPV6.matcher(server).matches()
+ || PATTERN_IPV6_6HEX4DEC.matcher(server).matches()
+ || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches()
+ || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches());
+ }
+
+ private static String getBase64Hash(X509Certificate certificate, String digest) throws CertificateEncodingException {
+ MessageDigest md;
+ try {
+ md = MessageDigest.getInstance(digest);
+ } catch (NoSuchAlgorithmException e) {
+ return null;
+ }
+ md.update(certificate.getEncoded());
+ return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
+ }
+
+ private static String hexString(byte[] data) {
+ StringBuffer si = new StringBuffer();
+ for (int i = 0; i < data.length; i++) {
+ si.append(String.format("%02x", data[i]));
+ if (i < data.length - 1)
+ si.append(":");
+ }
+ return si.toString();
+ }
+
+ private static String certHash(final X509Certificate cert, String digest) {
+ try {
+ MessageDigest md = MessageDigest.getInstance(digest);
+ md.update(cert.getEncoded());
+ return hexString(md.digest());
+ } catch (java.security.cert.CertificateEncodingException e) {
+ return e.getMessage();
+ } catch (java.security.NoSuchAlgorithmException e) {
+ return e.getMessage();
+ }
+ }
+
+ public static void interactResult(int decisionId, int choice) {
+ MTMDecision d;
+ synchronized (openDecisions) {
+ d = openDecisions.get(decisionId);
+ openDecisions.remove(decisionId);
+ }
+ if (d == null) {
+ LOGGER.log(Level.SEVERE, "interactResult: aborting due to stale decision reference!");
+ return;
+ }
+ synchronized (d) {
+ d.state = choice;
+ d.notify();
+ }
+ }
+
+ void init(Context m) {
+ master = m;
+ masterHandler = new Handler(m.getMainLooper());
+ notificationManager = (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE);
+
+ Application app;
+ if (m instanceof Application) {
+ app = (Application) m;
+ } else if (m instanceof Service) {
+ app = ((Service) m).getApplication();
+ } else if (m instanceof AppCompatActivity) {
+ app = ((AppCompatActivity) m).getApplication();
+ } else
+ throw new ClassCastException("MemorizingTrustManager context must be either Activity or Service!");
+
+ File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE);
+ keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE);
+
+ poshCacheDir = app.getCacheDir().getAbsolutePath() + "/posh_cache/";
+
+ appKeyStore = loadAppKeyStore();
+ }
+
+ /**
+ * Binds an Activity to the MTM for displaying the query dialog.
+ *
+ * This is useful if your connection is run from a service that is
+ * triggered by user interaction -- in such cases the activity is
+ * visible and the user tends to ignore the service notification.
+ *
+ * You should never have a hidden activity bound to MTM! Use this
+ * function in onResume() and @see unbindDisplayActivity in onPause().
+ *
+ * @param act Activity to be bound
+ */
+ public void bindDisplayActivity(AppCompatActivity act) {
+ foregroundAct = act;
+ }
+
+ /**
+ * Removes an Activity from the MTM display stack.
+ *
+ * Always call this function when the Activity added with
+ * {@link #bindDisplayActivity(AppCompatActivity)} is hidden.
+ *
+ * @param act Activity to be unbound
+ */
+ public void unbindDisplayActivity(AppCompatActivity act) {
+ // do not remove if it was overridden by a different activity
+ if (foregroundAct == act)
+ foregroundAct = null;
+ }
+
+ /**
+ * Get a list of all certificate aliases stored in MTM.
+ *
+ * @return an {@link Enumeration} of all certificates
+ */
+ public Enumeration getCertificates() {
+ try {
+ return appKeyStore.aliases();
+ } catch (KeyStoreException e) {
+ // this should never happen, however...
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Get a certificate for a given alias.
+ *
+ * @param alias the certificate's alias as returned by {@link #getCertificates()}.
+ * @return the certificate associated with the alias or null if none found.
+ */
+ public Certificate getCertificate(String alias) {
+ try {
+ return appKeyStore.getCertificate(alias);
+ } catch (KeyStoreException e) {
+ // this should never happen, however...
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Removes the given certificate from MTMs key store.
+ *
+ *
+ * WARNING: this does not immediately invalidate the certificate. It is
+ * well possible that (a) data is transmitted over still existing connections or
+ * (b) new connections are created using TLS renegotiation, without a new cert
+ * check.
+ *
+ *
+ * @param alias the certificate's alias as returned by {@link #getCertificates()}.
+ * @throws KeyStoreException if the certificate could not be deleted.
+ */
+ public void deleteCertificate(String alias) throws KeyStoreException {
+ appKeyStore.deleteEntry(alias);
+ keyStoreUpdated();
+ }
+
+ /**
+ * Creates a new hostname verifier supporting user interaction.
+ *
+ *
This method creates a new {@link HostnameVerifier} that is bound to
+ * the given instance of {@link MemorizingTrustManager}, and leverages an
+ * existing {@link HostnameVerifier}. The returned verifier performs the
+ * following steps, returning as soon as one of them succeeds:
+ * /p>
+ *
+ *
Success, if the wrapped defaultVerifier accepts the certificate.
+ *
Success, if the server certificate is stored in the keystore under the given hostname.
+ *
Ask the user and return accordingly.
+ *
Failure on exception.
+ *
+ *
+ * @param defaultVerifier the {@link HostnameVerifier} that should perform the actual check
+ * @return a new hostname verifier using the MTM's key store
+ * @throws IllegalArgumentException if the defaultVerifier parameter is null
+ */
+ public DomainHostnameVerifier wrapHostnameVerifier(final HostnameVerifier defaultVerifier, final boolean interactive) {
+ if (defaultVerifier == null)
+ throw new IllegalArgumentException("The default verifier may not be null");
+
+ return new MemorizingHostnameVerifier(defaultVerifier, interactive);
+ }
+
+ X509TrustManager getTrustManager(KeyStore ks) {
+ try {
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
+ tmf.init(ks);
+ for (TrustManager t : tmf.getTrustManagers()) {
+ if (t instanceof X509TrustManager) {
+ return (X509TrustManager) t;
+ }
+ }
+ } catch (Exception e) {
+ // Here, we are covering up errors. It might be more useful
+ // however to throw them out of the constructor so the
+ // embedding app knows something went wrong.
+ LOGGER.log(Level.SEVERE, "getTrustManager(" + ks + ")", e);
+ }
+ return null;
+ }
+
+ KeyStore loadAppKeyStore() {
+ KeyStore ks;
+ try {
+ ks = KeyStore.getInstance(KeyStore.getDefaultType());
+ } catch (KeyStoreException e) {
+ LOGGER.log(Level.SEVERE, "getAppKeyStore()", e);
+ return null;
+ }
+ FileInputStream fileInputStream = null;
+ try {
+ ks.load(null, null);
+ fileInputStream = new FileInputStream(keyStoreFile);
+ ks.load(fileInputStream, "MTM".toCharArray());
+ } catch (java.io.FileNotFoundException e) {
+ LOGGER.log(Level.INFO, "getAppKeyStore(" + keyStoreFile + ") - file does not exist");
+ } catch (Exception e) {
+ LOGGER.log(Level.SEVERE, "getAppKeyStore(" + keyStoreFile + ")", e);
+ } finally {
+ FileBackend.close(fileInputStream);
+ }
+ return ks;
+ }
+
+ void storeCert(String alias, Certificate cert) {
+ try {
+ appKeyStore.setCertificateEntry(alias, cert);
+ } catch (KeyStoreException e) {
+ LOGGER.log(Level.SEVERE, "storeCert(" + cert + ")", e);
+ return;
+ }
+ keyStoreUpdated();
+ }
+
+ void storeCert(X509Certificate cert) {
+ storeCert(cert.getSubjectDN().toString(), cert);
+ }
+
+ void keyStoreUpdated() {
+ // reload appTrustManager
+ appTrustManager = getTrustManager(appKeyStore);
+
+ // store KeyStore to file
+ java.io.FileOutputStream fos = null;
+ try {
+ fos = new java.io.FileOutputStream(keyStoreFile);
+ appKeyStore.store(fos, "MTM".toCharArray());
+ } catch (Exception e) {
+ LOGGER.log(Level.SEVERE, "storeCert(" + keyStoreFile + ")", e);
+ } finally {
+ if (fos != null) {
+ try {
+ fos.close();
+ } catch (IOException e) {
+ LOGGER.log(Level.SEVERE, "storeCert(" + keyStoreFile + ")", e);
+ }
+ }
+ }
+ }
+
+ // if the certificate is stored in the app key store, it is considered "known"
+ private boolean isCertKnown(X509Certificate cert) {
+ try {
+ return appKeyStore.getCertificateAlias(cert) != null;
+ } catch (KeyStoreException e) {
+ return false;
+ }
+ }
+
+ private boolean isExpiredException(Throwable e) {
+ do {
+ if (e instanceof CertificateExpiredException)
+ return true;
+ e = e.getCause();
+ } while (e != null);
+ return false;
+ }
+
+ public void checkCertTrusted(X509Certificate[] chain, String authType, String domain, boolean isServer, boolean interactive)
+ throws CertificateException {
+ LOGGER.log(Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")");
+ try {
+ LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager");
+ if (isServer)
+ appTrustManager.checkServerTrusted(chain, authType);
+ else
+ appTrustManager.checkClientTrusted(chain, authType);
+ } catch (CertificateException ae) {
+ LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae);
+ // if the cert is stored in our appTrustManager, we ignore expiredness
+ if (isExpiredException(ae)) {
+ LOGGER.log(Level.INFO, "checkCertTrusted: accepting expired certificate from keystore");
+ return;
+ }
+ if (isCertKnown(chain[0])) {
+ LOGGER.log(Level.INFO, "checkCertTrusted: accepting cert already stored in keystore");
+ return;
+ }
+ try {
+ if (defaultTrustManager == null)
+ throw ae;
+ LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager");
+ if (isServer)
+ defaultTrustManager.checkServerTrusted(chain, authType);
+ else
+ defaultTrustManager.checkClientTrusted(chain, authType);
+ } catch (CertificateException e) {
+ boolean trustSystemCAs = !PreferenceManager.getDefaultSharedPreferences(master).getBoolean("dont_trust_system_cas", false);
+ if (domain != null && isServer && trustSystemCAs && !isIp(domain)) {
+ final String hash = getBase64Hash(chain[0], "SHA-256");
+ final List fingerprints = getPoshFingerprints(domain);
+ if (hash != null && fingerprints.size() > 0) {
+ if (fingerprints.contains(hash)) {
+ Log.d("mtm", "trusted cert fingerprint of " + domain + " via posh");
+ return;
+ }
+ if (getPoshCacheFile(domain).delete()) {
+ Log.d("mtm", "deleted posh file for " + domain + " after not being able to verify");
+ }
+ }
+ }
+ if (interactive) {
+ interactCert(chain, authType, e);
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+
+ private List getPoshFingerprints(String domain) {
+ List cached = getPoshFingerprintsFromCache(domain);
+ if (cached == null) {
+ return getPoshFingerprintsFromServer(domain);
+ } else {
+ return cached;
+ }
+ }
+
+ private List getPoshFingerprintsFromServer(String domain) {
+ return getPoshFingerprintsFromServer(domain, "https://" + domain + "/.well-known/posh/xmpp-client.json", -1, true);
+ }
+
+ private List getPoshFingerprintsFromServer(String domain, String url, int maxTtl, boolean followUrl) {
+ Log.d("mtm", "downloading json for " + domain + " from " + url);
+ try {
+ List results = new ArrayList<>();
+ HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
+ connection.setConnectTimeout(5000);
+ connection.setReadTimeout(5000);
+ BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
+ String inputLine;
+ StringBuilder builder = new StringBuilder();
+ while ((inputLine = in.readLine()) != null) {
+ builder.append(inputLine);
+ }
+ JSONObject jsonObject = new JSONObject(builder.toString());
+ in.close();
+ int expires = jsonObject.getInt("expires");
+ if (expires <= 0) {
+ return new ArrayList<>();
+ }
+ if (maxTtl >= 0) {
+ expires = Math.min(maxTtl, expires);
+ }
+ String redirect;
+ try {
+ redirect = jsonObject.getString("url");
+ } catch (JSONException e) {
+ redirect = null;
+ }
+ if (followUrl && redirect != null && redirect.toLowerCase().startsWith("https")) {
+ return getPoshFingerprintsFromServer(domain, redirect, expires, false);
+ }
+ JSONArray fingerprints = jsonObject.getJSONArray("fingerprints");
+ for (int i = 0; i < fingerprints.length(); i++) {
+ JSONObject fingerprint = fingerprints.getJSONObject(i);
+ String sha256 = fingerprint.getString("sha-256");
+ if (sha256 != null) {
+ results.add(sha256);
+ }
+ }
+ writeFingerprintsToCache(domain, results, 1000L * expires + System.currentTimeMillis());
+ return results;
+ } catch (Exception e) {
+ Log.d("mtm", "error fetching posh " + e.getMessage());
+ return new ArrayList<>();
+ }
+ }
+
+ private File getPoshCacheFile(String domain) {
+ return new File(poshCacheDir + domain + ".json");
+ }
+
+ private void writeFingerprintsToCache(String domain, List results, long expires) {
+ File file = getPoshCacheFile(domain);
+ file.getParentFile().mkdirs();
+ try {
+ file.createNewFile();
+ JSONObject jsonObject = new JSONObject();
+ jsonObject.put("expires", expires);
+ jsonObject.put("fingerprints", new JSONArray(results));
+ FileOutputStream outputStream = new FileOutputStream(file);
+ outputStream.write(jsonObject.toString().getBytes());
+ outputStream.flush();
+ outputStream.close();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private List getPoshFingerprintsFromCache(String domain) {
+ File file = getPoshCacheFile(domain);
+ try {
+ InputStream is = new FileInputStream(file);
+ BufferedReader buf = new BufferedReader(new InputStreamReader(is));
+
+ String line = buf.readLine();
+ StringBuilder sb = new StringBuilder();
+
+ while (line != null) {
+ sb.append(line).append("\n");
+ line = buf.readLine();
+ }
+ JSONObject jsonObject = new JSONObject(sb.toString());
+ is.close();
+ long expires = jsonObject.getLong("expires");
+ long expiresIn = expires - System.currentTimeMillis();
+ if (expiresIn < 0) {
+ file.delete();
+ return null;
+ } else {
+ Log.d("mtm", "posh fingerprints expire in " + (expiresIn / 1000) + "s");
+ }
+ List result = new ArrayList<>();
+ JSONArray jsonArray = jsonObject.getJSONArray("fingerprints");
+ for (int i = 0; i < jsonArray.length(); ++i) {
+ result.add(jsonArray.getString(i));
+ }
+ return result;
+ } catch (FileNotFoundException e) {
+ return null;
+ } catch (IOException e) {
+ return null;
+ } catch (JSONException e) {
+ file.delete();
+ return null;
+ }
+ }
+
+ private X509Certificate[] getAcceptedIssuers() {
+ LOGGER.log(Level.FINE, "getAcceptedIssuers()");
+ return defaultTrustManager.getAcceptedIssuers();
+ }
+
+ private int createDecisionId(MTMDecision d) {
+ int myId;
+ synchronized (openDecisions) {
+ myId = decisionId;
+ openDecisions.put(myId, d);
+ decisionId += 1;
+ }
+ return myId;
+ }
+
+ private void certDetails(StringBuffer si, X509Certificate c) {
+ SimpleDateFormat validityDateFormater = new SimpleDateFormat("yyyy-MM-dd");
+ si.append("\n");
+ si.append(c.getSubjectDN().toString());
+ si.append("\n");
+ si.append(validityDateFormater.format(c.getNotBefore()));
+ si.append(" - ");
+ si.append(validityDateFormater.format(c.getNotAfter()));
+ si.append("\nSHA-256: ");
+ si.append(certHash(c, "SHA-256"));
+ si.append("\nSHA-1: ");
+ si.append(certHash(c, "SHA-1"));
+ si.append("\nSigned by: ");
+ si.append(c.getIssuerDN().toString());
+ si.append("\n");
+ }
+
+ private String certChainMessage(final X509Certificate[] chain, CertificateException cause) {
+ Throwable e = cause;
+ LOGGER.log(Level.FINE, "certChainMessage for " + e);
+ StringBuffer si = new StringBuffer();
+ if (e.getCause() != null) {
+ e = e.getCause();
+ // HACK: there is no sane way to check if the error is a "trust anchor
+ // not found", so we use string comparison.
+ if (NO_TRUST_ANCHOR.equals(e.getMessage())) {
+ si.append(master.getString(R.string.mtm_trust_anchor));
+ } else
+ si.append(e.getLocalizedMessage());
+ si.append("\n");
+ }
+ si.append("\n");
+ si.append(master.getString(R.string.mtm_connect_anyway));
+ si.append("\n\n");
+ si.append(master.getString(R.string.mtm_cert_details));
+ for (X509Certificate c : chain) {
+ certDetails(si, c);
+ }
+ return si.toString();
+ }
+
+ private String hostNameMessage(X509Certificate cert, String hostname) {
+ StringBuffer si = new StringBuffer();
+
+ si.append(master.getString(R.string.mtm_hostname_mismatch, hostname));
+ si.append("\n\n");
+ try {
+ Collection> sans = cert.getSubjectAlternativeNames();
+ if (sans == null) {
+ si.append(cert.getSubjectDN());
+ si.append("\n");
+ } else for (List> altName : sans) {
+ Object name = altName.get(1);
+ if (name instanceof String) {
+ si.append("[");
+ si.append((Integer) altName.get(0));
+ si.append("] ");
+ si.append(name);
+ si.append("\n");
+ }
+ }
+ } catch (CertificateParsingException e) {
+ e.printStackTrace();
+ si.append("\n");
+ }
+ si.append("\n");
+ si.append(master.getString(R.string.mtm_connect_anyway));
+ si.append("\n\n");
+ si.append(master.getString(R.string.mtm_cert_details));
+ certDetails(si, cert);
+ return si.toString();
+ }
+
+ /**
+ * Returns the top-most entry of the activity stack.
+ *
+ * @return the Context of the currently bound UI or the master context if none is bound
+ */
+ Context getUI() {
+ return (foregroundAct != null) ? foregroundAct : master;
+ }
+
+ int interact(final String message, final int titleId) {
+ /* prepare the MTMDecision blocker object */
+ MTMDecision choice = new MTMDecision();
+ final int myId = createDecisionId(choice);
+
+ masterHandler.post(new Runnable() {
+ public void run() {
+ Intent ni = new Intent(master, MemorizingActivity.class);
+ ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId));
+ ni.putExtra(DECISION_INTENT_ID, myId);
+ ni.putExtra(DECISION_INTENT_CERT, message);
+ ni.putExtra(DECISION_TITLE_ID, titleId);
+
+ // we try to directly start the activity and fall back to
+ // making a notification
+ try {
+ getUI().startActivity(ni);
+ } catch (Exception e) {
+ LOGGER.log(Level.FINE, "startActivity(MemorizingActivity)", e);
+ }
+ }
+ });
+
+ LOGGER.log(Level.FINE, "openDecisions: " + openDecisions + ", waiting on " + myId);
+ try {
+ synchronized (choice) {
+ choice.wait();
+ }
+ } catch (InterruptedException e) {
+ LOGGER.log(Level.FINER, "InterruptedException", e);
+ }
+ LOGGER.log(Level.FINE, "finished wait on " + myId + ": " + choice.state);
+ return choice.state;
+ }
+
+ void interactCert(final X509Certificate[] chain, String authType, CertificateException cause)
+ throws CertificateException {
+ switch (interact(certChainMessage(chain, cause), R.string.mtm_accept_cert)) {
+ case MTMDecision.DECISION_ALWAYS:
+ storeCert(chain[0]); // only store the server cert, not the whole chain
+ case MTMDecision.DECISION_ONCE:
+ break;
+ default:
+ throw (cause);
+ }
+ }
+
+ boolean interactHostname(X509Certificate cert, String hostname) {
+ switch (interact(hostNameMessage(cert, hostname), R.string.mtm_accept_servername)) {
+ case MTMDecision.DECISION_ALWAYS:
+ storeCert(hostname, cert);
+ case MTMDecision.DECISION_ONCE:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public X509TrustManager getNonInteractive(String domain) {
+ return new NonInteractiveMemorizingTrustManager(domain);
+ }
+
+ public X509TrustManager getInteractive(String domain) {
+ return new InteractiveMemorizingTrustManager(domain);
+ }
+
+ public X509TrustManager getNonInteractive() {
+ return new NonInteractiveMemorizingTrustManager(null);
+ }
+
+ public X509TrustManager getInteractive() {
+ return new InteractiveMemorizingTrustManager(null);
+ }
+
+ class MemorizingHostnameVerifier implements DomainHostnameVerifier {
+ private final HostnameVerifier defaultVerifier;
+ private final boolean interactive;
+
+ public MemorizingHostnameVerifier(HostnameVerifier wrapped, boolean interactive) {
+ this.defaultVerifier = wrapped;
+ this.interactive = interactive;
+ }
+
+ @Override
+ public boolean verify(String domain, String hostname, SSLSession session) {
+ LOGGER.log(Level.FINE, "hostname verifier for " + domain + ", trying default verifier first");
+ // if the default verifier accepts the hostname, we are done
+ if (defaultVerifier instanceof DomainHostnameVerifier) {
+ if (((DomainHostnameVerifier) defaultVerifier).verify(domain, hostname, session)) {
+ return true;
+ }
+ } else {
+ if (defaultVerifier.verify(domain, session)) {
+ return true;
+ }
+ }
+
+
+ // otherwise, we check if the hostname is an alias for this cert in our keystore
+ try {
+ X509Certificate cert = (X509Certificate) session.getPeerCertificates()[0];
+ //Log.d(TAG, "cert: " + cert);
+ if (cert.equals(appKeyStore.getCertificate(domain.toLowerCase(Locale.US)))) {
+ LOGGER.log(Level.FINE, "certificate for " + domain + " is in our keystore. accepting.");
+ return true;
+ } else {
+ LOGGER.log(Level.FINE, "server " + domain + " provided wrong certificate, asking user.");
+ if (interactive) {
+ return interactHostname(cert, domain);
+ } else {
+ return false;
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @Override
+ public boolean verify(String domain, SSLSession sslSession) {
+ return verify(domain, null, sslSession);
+ }
+ }
+
+ private class NonInteractiveMemorizingTrustManager implements X509TrustManager {
+
+ private final String domain;
+
+ public NonInteractiveMemorizingTrustManager(String domain) {
+ this.domain = domain;
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, false);
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType)
+ throws CertificateException {
+ MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, true, false);
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return MemorizingTrustManager.this.getAcceptedIssuers();
+ }
+
+ }
+
+ private class InteractiveMemorizingTrustManager implements X509TrustManager {
+ private final String domain;
+
+ public InteractiveMemorizingTrustManager(String domain) {
+ this.domain = domain;
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, false, true);
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType)
+ throws CertificateException {
+ MemorizingTrustManager.this.checkCertTrusted(chain, authType, domain, true, true);
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return MemorizingTrustManager.this.getAcceptedIssuers();
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/services/NotificationService.java b/src/main/java/eu/siacs/conversations/services/NotificationService.java
index a5ed8c67b..df3fd610d 100644
--- a/src/main/java/eu/siacs/conversations/services/NotificationService.java
+++ b/src/main/java/eu/siacs/conversations/services/NotificationService.java
@@ -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> notifications = new LinkedHashMap<>();
private final HashMap 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) {
+ 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) {
+ 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 messages : notifications.values()) {
@@ -652,7 +801,7 @@ public class NotificationService {
return builder.build();
}
- private void modifyForTextOnly(final Builder builder, final ArrayList messages) {
+ private void modifyForTextOnly(final Builder builder, final ArrayList 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 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);
diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
index 63d6a1911..0efa27618 100644
--- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
+++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
@@ -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 = 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 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 mOnUpdateBlocklist = Collections.newSetFromMap(new WeakHashMap());
private final Set mOnMucRosterUpdate = Collections.newSetFromMap(new WeakHashMap());
private final Set mOnKeyStatusUpdated = Collections.newSetFromMap(new WeakHashMap());
+ private final Set onJingleRtpConnectionUpdate = Collections.newSetFromMap(new WeakHashMap());
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) {
+ 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 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 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;
+
+ public OngoingCall(AbstractJingleConnection.Id id, Set 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);
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java b/src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java
index 567a2f3d6..df129152b 100644
--- a/src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java
@@ -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 accounts = AccountUtils.getEnabledAccounts(xmppConnectionService);
+ final List 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 account = new AtomicReference<>(accounts.get(0));
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(R.string.choose_account);
diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java
index 086ab7101..2d4129bbd 100644
--- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java
@@ -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 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 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);
});
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
index 036e35633..0f47f6936 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
@@ -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();
@@ -2740,10 +2799,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 +2820,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()) {
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
index bc9d78f71..cd6a1d8e6 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
@@ -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 {
diff --git a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java
index c2bdd05de..9e829e041 100644
--- a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java
@@ -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 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()) {
diff --git a/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java b/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java
index b69f189a1..9d41be658 100644
--- a/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/OmemoActivity.java
@@ -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);
diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
new file mode 100644
index 000000000..33a82e977
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
@@ -0,0 +1,827 @@
+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.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 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 rtpConnectionReference;
+
+ private ActivityRtpSessionBinding binding;
+ private PowerManager.WakeLock mProximityWakeLock;
+
+ private static Set 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 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) {
+ 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) {
+ 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 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 remoteVideo = jingleRtpConnection.getRemoteVideoTrack();
+ if (remoteVideo.isPresent()) {
+ remoteVideo.get().removeSink(binding.remoteVideo);
+ }
+ final Optional 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()) {
+ enterPictureInPictureMode(
+ new PictureInPictureParams.Builder()
+ .setAspectRatio(new Rational(10, 16))
+ .build()
+ );
+ }
+ }
+ }
+
+ 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 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 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 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 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 = 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;
+ } else if (END_CARD.contains(state)) {
+ resetIntent(account, with, state, requireRtpConnection().getMedia());
+ }
+ runOnUiThread(() -> {
+ updateStateDisplay(state);
+ updateButtonConfiguration(state);
+ updateVideoViews(state);
+ updateProfilePicture(state);
+ });
+ } else {
+ Log.d(Config.LOGTAG, "received update for other rtp session");
+ //TODO if we only ever have one; we might just switch over? Maybe!
+ }
+ }
+
+ @Override
+ public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set 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) {
+ 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);
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java
index f476736fa..b2fee69f9 100644
--- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java
@@ -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) {
diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
index c68bc537b..1a7dc8e4f 100644
--- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
@@ -49,6 +49,7 @@ import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.Message.FileParams;
+import eu.siacs.conversations.entities.RtpSessionStatus;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.http.P1S3UrlStreamHandler;
import eu.siacs.conversations.persistance.FileBackend;
@@ -72,918 +73,945 @@ import eu.siacs.conversations.utils.Emoticons;
import eu.siacs.conversations.utils.GeoHelper;
import eu.siacs.conversations.utils.MessageUtils;
import eu.siacs.conversations.utils.StylingHelper;
+import eu.siacs.conversations.utils.TimeframeUtils;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xmpp.mam.MamReference;
import rocks.xmpp.addr.Jid;
public class MessageAdapter extends ArrayAdapter implements CopyTextView.CopyHandler {
- public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
- private static final int SENT = 0;
- private static final int RECEIVED = 1;
- private static final int STATUS = 2;
- private static final int DATE_SEPARATOR = 3;
- private final XmppActivity activity;
- private final ListSelectionManager listSelectionManager = new ListSelectionManager();
- private final AudioPlayer audioPlayer;
- private List highlightedTerm = null;
- private DisplayMetrics metrics;
- private OnContactPictureClicked mOnContactPictureClickedListener;
- private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
- private OnQuoteListener onQuoteListener;
- public MessageAdapter(XmppActivity activity, List messages) {
- super(activity, 0, messages);
- this.audioPlayer = new AudioPlayer(this);
- this.activity = activity;
- metrics = getContext().getResources().getDisplayMetrics();
- updatePreferences();
- }
+ public static final String DATE_SEPARATOR_BODY = "DATE_SEPARATOR";
+ private static final int SENT = 0;
+ private static final int RECEIVED = 1;
+ private static final int STATUS = 2;
+ private static final int DATE_SEPARATOR = 3;
+ private static final int RTP_SESSION = 4;
+ private final XmppActivity activity;
+ private final ListSelectionManager listSelectionManager = new ListSelectionManager();
+ private final AudioPlayer audioPlayer;
+ private List highlightedTerm = null;
+ private DisplayMetrics metrics;
+ private OnContactPictureClicked mOnContactPictureClickedListener;
+ private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
+ private OnQuoteListener onQuoteListener;
+
+ public MessageAdapter(XmppActivity activity, List messages) {
+ super(activity, 0, messages);
+ this.audioPlayer = new AudioPlayer(this);
+ this.activity = activity;
+ metrics = getContext().getResources().getDisplayMetrics();
+ updatePreferences();
+ }
+ private static void resetClickListener(View... views) {
+ for (View view : views) {
+ view.setOnClickListener(null);
+ }
+ }
- private static void resetClickListener(View... views) {
- for (View view : views) {
- view.setOnClickListener(null);
- }
- }
+ public void flagScreenOn() {
+ activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
- public void flagScreenOn() {
- activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
- }
+ public void flagScreenOff() {
+ activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
- public void flagScreenOff() {
- activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
- }
+ public void setOnContactPictureClicked(OnContactPictureClicked listener) {
+ this.mOnContactPictureClickedListener = listener;
+ }
- public void setOnContactPictureClicked(OnContactPictureClicked listener) {
- this.mOnContactPictureClickedListener = listener;
- }
+ public Activity getActivity() {
+ return activity;
+ }
- public Activity getActivity() {
- return activity;
- }
+ public void setOnContactPictureLongClicked(
+ OnContactPictureLongClicked listener) {
+ this.mOnContactPictureLongClickedListener = listener;
+ }
- public void setOnContactPictureLongClicked(
- OnContactPictureLongClicked listener) {
- this.mOnContactPictureLongClickedListener = listener;
- }
+ public void setOnQuoteListener(OnQuoteListener listener) {
+ this.onQuoteListener = listener;
+ }
- public void setOnQuoteListener(OnQuoteListener listener) {
- this.onQuoteListener = listener;
- }
+ @Override
+ public int getViewTypeCount() {
+ return 5;
+ }
- @Override
- public int getViewTypeCount() {
- return 4;
- }
+ private int getItemViewType(Message message) {
+ if (message.getType() == Message.TYPE_STATUS) {
+ if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
+ return DATE_SEPARATOR;
+ } else {
+ return STATUS;
+ }
+ } else if (message.getType() == Message.TYPE_RTP_SESSION) {
+ return RTP_SESSION;
+ } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
+ return RECEIVED;
+ } else {
+ return SENT;
+ }
+ }
- private int getItemViewType(Message message) {
- if (message.getType() == Message.TYPE_STATUS) {
- if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
- return DATE_SEPARATOR;
- } else {
- return STATUS;
- }
- } else if (message.getStatus() <= Message.STATUS_RECEIVED) {
- return RECEIVED;
- }
+ @Override
+ public int getItemViewType(int position) {
+ return this.getItemViewType(getItem(position));
+ }
- return SENT;
- }
+ private int getMessageTextColor(boolean onDark, boolean primary) {
+ if (onDark) {
+ return ContextCompat.getColor(activity, primary ? R.color.white : R.color.white70);
+ } else {
+ return ContextCompat.getColor(activity, primary ? R.color.black87 : R.color.black54);
+ }
+ }
- @Override
- public int getItemViewType(int position) {
- return this.getItemViewType(getItem(position));
- }
+ private void displayStatus(ViewHolder viewHolder, Message message, int type, boolean darkBackground) {
+ String filesize = null;
+ String info = null;
+ boolean error = false;
+ if (viewHolder.indicatorReceived != null) {
+ viewHolder.indicatorReceived.setVisibility(View.GONE);
+ }
- private int getMessageTextColor(boolean onDark, boolean primary) {
- if (onDark) {
- return ContextCompat.getColor(activity, primary ? R.color.white : R.color.white70);
- } else {
- return ContextCompat.getColor(activity, primary ? R.color.black87 : R.color.black54);
- }
- }
+ if (viewHolder.edit_indicator != null) {
+ if (message.edited()) {
+ viewHolder.edit_indicator.setVisibility(View.VISIBLE);
+ viewHolder.edit_indicator.setImageResource(darkBackground ? R.drawable.ic_mode_edit_white_18dp : R.drawable.ic_mode_edit_black_18dp);
+ viewHolder.edit_indicator.setAlpha(darkBackground ? 0.7f : 0.57f);
+ } else {
+ viewHolder.edit_indicator.setVisibility(View.GONE);
+ }
+ }
+ final Transferable transferable = message.getTransferable();
+ boolean multiReceived = message.getConversation().getMode() == Conversation.MODE_MULTI
+ && message.getMergedStatus() <= Message.STATUS_RECEIVED;
+ if (message.isFileOrImage() || transferable != null || MessageUtils.unInitiatedButKnownSize(message)) {
+ FileParams params = message.getFileParams();
+ filesize = params.size > 0 ? UIHelper.filesizeToString(params.size) : null;
+ if (transferable != null && (transferable.getStatus() == Transferable.STATUS_FAILED || transferable.getStatus() == Transferable.STATUS_CANCELLED)) {
+ error = true;
+ }
+ }
+ switch (message.getMergedStatus()) {
+ case Message.STATUS_WAITING:
+ info = getContext().getString(R.string.waiting);
+ break;
+ case Message.STATUS_UNSEND:
+ if (transferable != null) {
+ info = getContext().getString(R.string.sending_file, transferable.getProgress());
+ } else {
+ info = getContext().getString(R.string.sending);
+ }
+ break;
+ case Message.STATUS_OFFERED:
+ info = getContext().getString(R.string.offering);
+ break;
+ case Message.STATUS_SEND_RECEIVED:
+ case Message.STATUS_SEND_DISPLAYED:
+ viewHolder.indicatorReceived.setImageResource(darkBackground ? R.drawable.ic_done_white_18dp : R.drawable.ic_done_black_18dp);
+ viewHolder.indicatorReceived.setAlpha(darkBackground ? 0.7f : 0.57f);
+ viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
+ break;
+ case Message.STATUS_SEND_FAILED:
+ final String errorMessage = message.getErrorMessage();
+ if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
+ info = getContext().getString(R.string.cancelled);
+ } else if (errorMessage != null) {
+ final String[] errorParts = errorMessage.split("\\u001f", 2);
+ if (errorParts.length == 2) {
+ switch (errorParts[0]) {
+ case "file-too-large":
+ info = getContext().getString(R.string.file_too_large);
+ break;
+ default:
+ info = getContext().getString(R.string.send_failed);
+ break;
+ }
+ } else {
+ info = getContext().getString(R.string.send_failed);
+ }
+ } else {
+ info = getContext().getString(R.string.send_failed);
+ }
+ error = true;
+ break;
+ default:
+ if (multiReceived) {
+ info = UIHelper.getMessageDisplayName(message);
+ }
+ break;
+ }
+ if (error && type == SENT) {
+ if (darkBackground) {
+ viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning_OnDark);
+ } else {
+ viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning);
+ }
+ } else {
+ if (darkBackground) {
+ viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_OnDark);
+ } else {
+ viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption);
+ }
+ viewHolder.time.setTextColor(this.getMessageTextColor(darkBackground, false));
+ }
+ if (message.getEncryption() == Message.ENCRYPTION_NONE) {
+ viewHolder.indicator.setVisibility(View.GONE);
+ } else {
+ boolean verified = false;
+ if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
+ final FingerprintStatus status = message.getConversation()
+ .getAccount().getAxolotlService().getFingerprintTrust(
+ message.getFingerprint());
+ if (status != null && status.isVerified()) {
+ verified = true;
+ }
+ }
+ if (verified) {
+ viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_verified_user_white_18dp : R.drawable.ic_verified_user_black_18dp);
+ } else {
+ viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_lock_white_18dp : R.drawable.ic_lock_black_18dp);
+ }
+ if (darkBackground) {
+ viewHolder.indicator.setAlpha(0.7f);
+ } else {
+ viewHolder.indicator.setAlpha(0.57f);
+ }
+ viewHolder.indicator.setVisibility(View.VISIBLE);
+ }
- private void displayStatus(ViewHolder viewHolder, Message message, int type, boolean darkBackground) {
- String filesize = null;
- String info = null;
- boolean error = false;
- if (viewHolder.indicatorReceived != null) {
- viewHolder.indicatorReceived.setVisibility(View.GONE);
- }
+ final String formattedTime = UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
+ final String bodyLanguage = message.getBodyLanguage();
+ final String bodyLanguageInfo = bodyLanguage == null ? "" : String.format(" \u00B7 %s", bodyLanguage.toUpperCase(Locale.US));
+ if (message.getStatus() <= Message.STATUS_RECEIVED) {
+ if ((filesize != null) && (info != null)) {
+ viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + " \u00B7 " + info + bodyLanguageInfo);
+ } else if ((filesize == null) && (info != null)) {
+ viewHolder.time.setText(formattedTime + " \u00B7 " + info + bodyLanguageInfo);
+ } else if ((filesize != null) && (info == null)) {
+ viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + bodyLanguageInfo);
+ } else {
+ viewHolder.time.setText(formattedTime + bodyLanguageInfo);
+ }
+ } else {
+ if ((filesize != null) && (info != null)) {
+ viewHolder.time.setText(filesize + " \u00B7 " + info + bodyLanguageInfo);
+ } else if ((filesize == null) && (info != null)) {
+ if (error) {
+ viewHolder.time.setText(info + " \u00B7 " + formattedTime + bodyLanguageInfo);
+ } else {
+ viewHolder.time.setText(info);
+ }
+ } else if ((filesize != null) && (info == null)) {
+ viewHolder.time.setText(filesize + " \u00B7 " + formattedTime + bodyLanguageInfo);
+ } else {
+ viewHolder.time.setText(formattedTime + bodyLanguageInfo);
+ }
+ }
+ }
- if (viewHolder.edit_indicator != null) {
- if (message.edited()) {
- viewHolder.edit_indicator.setVisibility(View.VISIBLE);
- viewHolder.edit_indicator.setImageResource(darkBackground ? R.drawable.ic_mode_edit_white_18dp : R.drawable.ic_mode_edit_black_18dp);
- viewHolder.edit_indicator.setAlpha(darkBackground ? 0.7f : 0.57f);
- } else {
- viewHolder.edit_indicator.setVisibility(View.GONE);
- }
- }
- final Transferable transferable = message.getTransferable();
- boolean multiReceived = message.getConversation().getMode() == Conversation.MODE_MULTI
- && message.getMergedStatus() <= Message.STATUS_RECEIVED;
- if (message.isFileOrImage() || transferable != null || MessageUtils.unInitiatedButKnownSize(message)) {
- FileParams params = message.getFileParams();
- filesize = params.size > 0 ? UIHelper.filesizeToString(params.size) : null;
- if (transferable != null && (transferable.getStatus() == Transferable.STATUS_FAILED || transferable.getStatus() == Transferable.STATUS_CANCELLED)) {
- error = true;
- }
- }
- switch (message.getMergedStatus()) {
- case Message.STATUS_WAITING:
- info = getContext().getString(R.string.waiting);
- break;
- case Message.STATUS_UNSEND:
- if (transferable != null) {
- info = getContext().getString(R.string.sending_file, transferable.getProgress());
- } else {
- info = getContext().getString(R.string.sending);
- }
- break;
- case Message.STATUS_OFFERED:
- info = getContext().getString(R.string.offering);
- break;
- case Message.STATUS_SEND_RECEIVED:
- case Message.STATUS_SEND_DISPLAYED:
- viewHolder.indicatorReceived.setImageResource(darkBackground ? R.drawable.ic_done_white_18dp : R.drawable.ic_done_black_18dp);
- viewHolder.indicatorReceived.setAlpha(darkBackground ? 0.7f : 0.57f);
- viewHolder.indicatorReceived.setVisibility(View.VISIBLE);
- break;
- case Message.STATUS_SEND_FAILED:
- final String errorMessage = message.getErrorMessage();
- if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
- info = getContext().getString(R.string.cancelled);
- } else if (errorMessage != null) {
- final String[] errorParts = errorMessage.split("\\u001f", 2);
- if (errorParts.length == 2) {
- switch (errorParts[0]) {
- case "file-too-large":
- info = getContext().getString(R.string.file_too_large);
- break;
- default:
- info = getContext().getString(R.string.send_failed);
- break;
- }
- } else {
- info = getContext().getString(R.string.send_failed);
- }
- } else {
- info = getContext().getString(R.string.send_failed);
- }
- error = true;
- break;
- default:
- if (multiReceived) {
- info = UIHelper.getMessageDisplayName(message);
- }
- break;
- }
- if (error && type == SENT) {
- if (darkBackground) {
- viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning_OnDark);
- } else {
- viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_Warning);
- }
- } else {
- if (darkBackground) {
- viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption_OnDark);
- } else {
- viewHolder.time.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Caption);
- }
- viewHolder.time.setTextColor(this.getMessageTextColor(darkBackground, false));
- }
- if (message.getEncryption() == Message.ENCRYPTION_NONE) {
- viewHolder.indicator.setVisibility(View.GONE);
- } else {
- boolean verified = false;
- if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
- final FingerprintStatus status = message.getConversation()
- .getAccount().getAxolotlService().getFingerprintTrust(
- message.getFingerprint());
- if (status != null && status.isVerified()) {
- verified = true;
- }
- }
- if (verified) {
- viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_verified_user_white_18dp : R.drawable.ic_verified_user_black_18dp);
- } else {
- viewHolder.indicator.setImageResource(darkBackground ? R.drawable.ic_lock_white_18dp : R.drawable.ic_lock_black_18dp);
- }
- if (darkBackground) {
- viewHolder.indicator.setAlpha(0.7f);
- } else {
- viewHolder.indicator.setAlpha(0.57f);
- }
- viewHolder.indicator.setVisibility(View.VISIBLE);
- }
+ private void displayInfoMessage(ViewHolder viewHolder, CharSequence text, boolean darkBackground) {
+ viewHolder.download_button.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.messageBody.setVisibility(View.VISIBLE);
+ viewHolder.messageBody.setText(text);
+ if (darkBackground) {
+ viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary_OnDark);
+ } else {
+ viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary);
+ }
+ viewHolder.messageBody.setTextIsSelectable(false);
+ }
- final String formattedTime = UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
- final String bodyLanguage = message.getBodyLanguage();
- final String bodyLanguageInfo = bodyLanguage == null ? "" : String.format(" \u00B7 %s", bodyLanguage.toUpperCase(Locale.US));
- if (message.getStatus() <= Message.STATUS_RECEIVED) { ;
- if ((filesize != null) && (info != null)) {
- viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + " \u00B7 " + info + bodyLanguageInfo);
- } else if ((filesize == null) && (info != null)) {
- viewHolder.time.setText(formattedTime + " \u00B7 " + info + bodyLanguageInfo);
- } else if ((filesize != null) && (info == null)) {
- viewHolder.time.setText(formattedTime + " \u00B7 " + filesize + bodyLanguageInfo);
- } else {
- viewHolder.time.setText(formattedTime+bodyLanguageInfo);
- }
- } else {
- if ((filesize != null) && (info != null)) {
- viewHolder.time.setText(filesize + " \u00B7 " + info + bodyLanguageInfo);
- } else if ((filesize == null) && (info != null)) {
- if (error) {
- viewHolder.time.setText(info + " \u00B7 " + formattedTime + bodyLanguageInfo);
- } else {
- viewHolder.time.setText(info);
- }
- } else if ((filesize != null) && (info == null)) {
- viewHolder.time.setText(filesize + " \u00B7 " + formattedTime + bodyLanguageInfo);
- } else {
- viewHolder.time.setText(formattedTime+bodyLanguageInfo);
- }
- }
- }
+ private void displayEmojiMessage(final ViewHolder viewHolder, final String body, final boolean darkBackground) {
+ viewHolder.download_button.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.messageBody.setVisibility(View.VISIBLE);
+ if (darkBackground) {
+ viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji_OnDark);
+ } else {
+ viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji);
+ }
+ Spannable span = new SpannableString(body);
+ float size = Emoticons.isEmoji(body) ? 3.0f : 2.0f;
+ span.setSpan(new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ viewHolder.messageBody.setText(EmojiWrapper.transform(span));
+ }
- private void displayInfoMessage(ViewHolder viewHolder, CharSequence text, boolean darkBackground) {
- viewHolder.download_button.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.messageBody.setVisibility(View.VISIBLE);
- viewHolder.messageBody.setText(text);
- if (darkBackground) {
- viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary_OnDark);
- } else {
- viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Secondary);
- }
- viewHolder.messageBody.setTextIsSelectable(false);
- }
+ private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) {
+ if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
+ body.insert(start++, "\n");
+ body.setSpan(new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ end++;
+ }
+ if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
+ body.insert(end, "\n");
+ body.setSpan(new DividerSpan(false), end, end + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ int color = darkBackground ? this.getMessageTextColor(darkBackground, false)
+ : ContextCompat.getColor(activity, R.color.green700_desaturated);
+ DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
+ body.setSpan(new QuoteSpan(color, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
- private void displayEmojiMessage(final ViewHolder viewHolder, final String body, final boolean darkBackground) {
- viewHolder.download_button.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.messageBody.setVisibility(View.VISIBLE);
- if (darkBackground) {
- viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji_OnDark);
- } else {
- viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji);
- }
- Spannable span = new SpannableString(body);
- float size = Emoticons.isEmoji(body) ? 3.0f : 2.0f;
- span.setSpan(new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- viewHolder.messageBody.setText(EmojiWrapper.transform(span));
- }
+ /**
+ * Applies QuoteSpan to group of lines which starts with > or » characters.
+ * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text.
+ */
+ private boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) {
+ boolean startsWithQuote = false;
+ char previous = '\n';
+ int lineStart = -1;
+ int lineTextStart = -1;
+ int quoteStart = -1;
+ for (int i = 0; i <= body.length(); i++) {
+ char current = body.length() > i ? body.charAt(i) : '\n';
+ if (lineStart == -1) {
+ if (previous == '\n') {
+ if ((current == '>' && UIHelper.isPositionFollowedByQuoteableCharacter(body, i))
+ || current == '\u00bb' && !UIHelper.isPositionFollowedByQuote(body, i)) {
+ // Line start with quote
+ lineStart = i;
+ if (quoteStart == -1) quoteStart = i;
+ if (i == 0) startsWithQuote = true;
+ } else if (quoteStart >= 0) {
+ // Line start without quote, apply spans there
+ applyQuoteSpan(body, quoteStart, i - 1, darkBackground);
+ quoteStart = -1;
+ }
+ }
+ } else {
+ // Remove extra spaces between > and first character in the line
+ // > character will be removed too
+ if (current != ' ' && lineTextStart == -1) {
+ lineTextStart = i;
+ }
+ if (current == '\n') {
+ body.delete(lineStart, lineTextStart);
+ i -= lineTextStart - lineStart;
+ if (i == lineStart) {
+ // Avoid empty lines because span over empty line can be hidden
+ body.insert(i++, " ");
+ }
+ lineStart = -1;
+ lineTextStart = -1;
+ }
+ }
+ previous = current;
+ }
+ if (quoteStart >= 0) {
+ // Apply spans to finishing open quote
+ applyQuoteSpan(body, quoteStart, body.length(), darkBackground);
+ }
+ return startsWithQuote;
+ }
- private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) {
- if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
- body.insert(start++, "\n");
- body.setSpan(new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- end++;
- }
- if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
- body.insert(end, "\n");
- body.setSpan(new DividerSpan(false), end, end + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- int color = darkBackground ? this.getMessageTextColor(darkBackground, false)
- : ContextCompat.getColor(activity, R.color.orange700_desaturated);
- DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
- body.setSpan(new QuoteSpan(color, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
+ private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) {
+ viewHolder.download_button.setVisibility(View.GONE);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.messageBody.setVisibility(View.VISIBLE);
- /**
- * Applies QuoteSpan to group of lines which starts with > or » characters.
- * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text.
- */
- private boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) {
- boolean startsWithQuote = false;
- char previous = '\n';
- int lineStart = -1;
- int lineTextStart = -1;
- int quoteStart = -1;
- for (int i = 0; i <= body.length(); i++) {
- char current = body.length() > i ? body.charAt(i) : '\n';
- if (lineStart == -1) {
- if (previous == '\n') {
- if ((current == '>' && UIHelper.isPositionFollowedByQuoteableCharacter(body, i))
- || current == '\u00bb' && !UIHelper.isPositionFollowedByQuote(body, i)) {
- // Line start with quote
- lineStart = i;
- if (quoteStart == -1) quoteStart = i;
- if (i == 0) startsWithQuote = true;
- } else if (quoteStart >= 0) {
- // Line start without quote, apply spans there
- applyQuoteSpan(body, quoteStart, i - 1, darkBackground);
- quoteStart = -1;
- }
- }
- } else {
- // Remove extra spaces between > and first character in the line
- // > character will be removed too
- if (current != ' ' && lineTextStart == -1) {
- lineTextStart = i;
- }
- if (current == '\n') {
- body.delete(lineStart, lineTextStart);
- i -= lineTextStart - lineStart;
- if (i == lineStart) {
- // Avoid empty lines because span over empty line can be hidden
- body.insert(i++, " ");
- }
- lineStart = -1;
- lineTextStart = -1;
- }
- }
- previous = current;
- }
- if (quoteStart >= 0) {
- // Apply spans to finishing open quote
- applyQuoteSpan(body, quoteStart, body.length(), darkBackground);
- }
- return startsWithQuote;
- }
+ if (darkBackground) {
+ viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_OnDark);
+ } else {
+ viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1);
+ }
+ viewHolder.messageBody.setHighlightColor(ContextCompat.getColor(activity, darkBackground ? R.color.black26 : R.color.grey500));
+ viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
- private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) {
- viewHolder.download_button.setVisibility(View.GONE);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.messageBody.setVisibility(View.VISIBLE);
+ if (message.getBody() != null) {
+ final String nick = UIHelper.getMessageDisplayName(message);
+ SpannableStringBuilder body = message.getMergedBody();
+ boolean hasMeCommand = message.hasMeCommand();
+ if (hasMeCommand) {
+ body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
+ }
+ if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
+ body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
+ body.append("\u2026");
+ }
+ Message.MergeSeparator[] mergeSeparators = body.getSpans(0, body.length(), Message.MergeSeparator.class);
+ for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
+ int start = body.getSpanStart(mergeSeparator);
+ int end = body.getSpanEnd(mergeSeparator);
+ body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ boolean startsWithQuote = handleTextQuotes(body, darkBackground);
+ if (!message.isPrivateMessage()) {
+ if (hasMeCommand) {
+ body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ } else {
+ String privateMarker;
+ if (message.getStatus() <= Message.STATUS_RECEIVED) {
+ privateMarker = activity.getString(R.string.private_message);
+ } else {
+ Jid cp = message.getCounterpart();
+ privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
+ }
+ body.insert(0, privateMarker);
+ int privateMarkerIndex = privateMarker.length();
+ if (startsWithQuote) {
+ body.insert(privateMarkerIndex, "\n\n");
+ body.setSpan(new DividerSpan(false), privateMarkerIndex, privateMarkerIndex + 2,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else {
+ body.insert(privateMarkerIndex, " ");
+ }
+ body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ if (hasMeCommand) {
+ body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), privateMarkerIndex + 1,
+ privateMarkerIndex + 1 + nick.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ if (message.getConversation().getMode() == Conversation.MODE_MULTI && message.getStatus() == Message.STATUS_RECEIVED) {
+ if (message.getConversation() instanceof Conversation) {
+ final Conversation conversation = (Conversation) message.getConversation();
+ Pattern pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualNick());
+ Matcher matcher = pattern.matcher(body);
+ while (matcher.find()) {
+ body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+ Matcher matcher = Emoticons.getEmojiPattern(body).matcher(body);
+ while (matcher.find()) {
+ if (matcher.start() < matcher.end()) {
+ body.setSpan(new RelativeSizeSpan(1.2f), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
- if (darkBackground) {
- viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_OnDark);
- } else {
- viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1);
- }
- viewHolder.messageBody.setHighlightColor(ContextCompat.getColor(activity, darkBackground
- ? (type == SENT ? R.color.black26 : R.color.grey800) : R.color.grey500));
- viewHolder.messageBody.setTypeface(null, Typeface.NORMAL);
+ StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
+ if (highlightedTerm != null) {
+ StylingHelper.highlight(activity, body, highlightedTerm, StylingHelper.isDarkText(viewHolder.messageBody));
+ }
+ MyLinkify.addLinks(body, true);
+ viewHolder.messageBody.setAutoLinkMask(0);
+ viewHolder.messageBody.setText(EmojiWrapper.transform(body));
+ viewHolder.messageBody.setTextIsSelectable(true);
+ viewHolder.messageBody.setMovementMethod(ClickableMovementMethod.getInstance());
+ listSelectionManager.onUpdate(viewHolder.messageBody, message);
+ } else {
+ viewHolder.messageBody.setText("");
+ viewHolder.messageBody.setTextIsSelectable(false);
+ }
+ }
- if (message.getBody() != null) {
- final String nick = UIHelper.getMessageDisplayName(message);
- SpannableStringBuilder body = message.getMergedBody();
- boolean hasMeCommand = message.hasMeCommand();
- if (hasMeCommand) {
- body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
- }
- if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
- body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
- body.append("\u2026");
- }
- Message.MergeSeparator[] mergeSeparators = body.getSpans(0, body.length(), Message.MergeSeparator.class);
- for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
- int start = body.getSpanStart(mergeSeparator);
- int end = body.getSpanEnd(mergeSeparator);
- body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- boolean startsWithQuote = handleTextQuotes(body, darkBackground);
- if (!message.isPrivateMessage()) {
- if (hasMeCommand) {
- body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(),
- Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- } else {
- String privateMarker;
- if (message.getStatus() <= Message.STATUS_RECEIVED) {
- privateMarker = activity.getString(R.string.private_message);
- } else {
- Jid cp = message.getCounterpart();
- privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
- }
- body.insert(0, privateMarker);
- int privateMarkerIndex = privateMarker.length();
- if (startsWithQuote) {
- body.insert(privateMarkerIndex, "\n\n");
- body.setSpan(new DividerSpan(false), privateMarkerIndex, privateMarkerIndex + 2,
- Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- } else {
- body.insert(privateMarkerIndex, " ");
- }
- body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- if (hasMeCommand) {
- body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), privateMarkerIndex + 1,
- privateMarkerIndex + 1 + nick.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- }
- if (message.getConversation().getMode() == Conversation.MODE_MULTI && message.getStatus() == Message.STATUS_RECEIVED) {
- if (message.getConversation() instanceof Conversation) {
- final Conversation conversation = (Conversation) message.getConversation();
- Pattern pattern = NotificationService.generateNickHighlightPattern(conversation.getMucOptions().getActualNick());
- Matcher matcher = pattern.matcher(body);
- while (matcher.find()) {
- body.setSpan(new StyleSpan(Typeface.BOLD), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- }
- }
- Matcher matcher = Emoticons.getEmojiPattern(body).matcher(body);
- while (matcher.find()) {
- if (matcher.start() < matcher.end()) {
- body.setSpan(new RelativeSizeSpan(1.2f), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- }
+ private void displayDownloadableMessage(ViewHolder viewHolder, final Message message, String text, final boolean darkBackground) {
+ toggleWhisperInfo(viewHolder, message, darkBackground);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.download_button.setVisibility(View.VISIBLE);
+ viewHolder.download_button.setText(text);
+ viewHolder.download_button.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
+ }
- StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
- if (highlightedTerm != null) {
- StylingHelper.highlight(activity, body, highlightedTerm, StylingHelper.isDarkText(viewHolder.messageBody));
- }
- MyLinkify.addLinks(body,true);
- viewHolder.messageBody.setAutoLinkMask(0);
- viewHolder.messageBody.setText(EmojiWrapper.transform(body));
- viewHolder.messageBody.setTextIsSelectable(true);
- viewHolder.messageBody.setMovementMethod(ClickableMovementMethod.getInstance());
- listSelectionManager.onUpdate(viewHolder.messageBody, message);
- } else {
- viewHolder.messageBody.setText("");
- viewHolder.messageBody.setTextIsSelectable(false);
- }
- }
+ private void displayOpenableMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+ toggleWhisperInfo(viewHolder, message, darkBackground);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.download_button.setVisibility(View.VISIBLE);
+ viewHolder.download_button.setText(activity.getString(R.string.open_x_file, UIHelper.getFileDescriptionString(activity, message)));
+ viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
+ }
- private void displayDownloadableMessage(ViewHolder viewHolder, final Message message, String text, final boolean darkBackground) {
- toggleWhisperInfo(viewHolder, message, darkBackground);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.download_button.setVisibility(View.VISIBLE);
- viewHolder.download_button.setText(text);
- viewHolder.download_button.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
- }
+ private void displayLocationMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+ toggleWhisperInfo(viewHolder, message, darkBackground);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.download_button.setVisibility(View.VISIBLE);
+ viewHolder.download_button.setText(R.string.show_location);
+ viewHolder.download_button.setOnClickListener(v -> showLocation(message));
+ }
- private void displayOpenableMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
- toggleWhisperInfo(viewHolder, message, darkBackground);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.download_button.setVisibility(View.VISIBLE);
- viewHolder.download_button.setText(activity.getString(R.string.open_x_file, UIHelper.getFileDescriptionString(activity, message)));
- viewHolder.download_button.setOnClickListener(v -> openDownloadable(message));
- }
+ private void displayAudioMessage(ViewHolder viewHolder, Message message, boolean darkBackground) {
+ toggleWhisperInfo(viewHolder, message, darkBackground);
+ viewHolder.image.setVisibility(View.GONE);
+ viewHolder.download_button.setVisibility(View.GONE);
+ final RelativeLayout audioPlayer = viewHolder.audioPlayer;
+ audioPlayer.setVisibility(View.VISIBLE);
+ AudioPlayer.ViewHolder.get(audioPlayer).setDarkBackground(darkBackground);
+ this.audioPlayer.init(audioPlayer, message);
+ }
- private void displayLocationMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
- toggleWhisperInfo(viewHolder, message, darkBackground);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.download_button.setVisibility(View.VISIBLE);
- viewHolder.download_button.setText(R.string.show_location);
- viewHolder.download_button.setOnClickListener(v -> showLocation(message));
- }
+ private void displayMediaPreviewMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+ toggleWhisperInfo(viewHolder, message, darkBackground);
+ viewHolder.download_button.setVisibility(View.GONE);
+ viewHolder.audioPlayer.setVisibility(View.GONE);
+ viewHolder.image.setVisibility(View.VISIBLE);
+ final FileParams params = message.getFileParams();
+ final double target = metrics.density * 288;
+ final int scaledW;
+ final int scaledH;
+ if (Math.max(params.height, params.width) * metrics.density <= target) {
+ scaledW = (int) (params.width * metrics.density);
+ scaledH = (int) (params.height * metrics.density);
+ } else if (Math.max(params.height, params.width) <= target) {
+ scaledW = params.width;
+ scaledH = params.height;
+ } else if (params.width <= params.height) {
+ scaledW = (int) (params.width / ((double) params.height / target));
+ scaledH = (int) target;
+ } else {
+ scaledW = (int) target;
+ scaledH = (int) (params.height / ((double) params.width / target));
+ }
+ LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(scaledW, scaledH);
+ layoutParams.setMargins(0, (int) (metrics.density * 4), 0, (int) (metrics.density * 4));
+ viewHolder.image.setLayoutParams(layoutParams);
+ activity.loadBitmap(message, viewHolder.image);
+ viewHolder.image.setOnClickListener(v -> openDownloadable(message));
+ }
- private void displayAudioMessage(ViewHolder viewHolder, Message message, boolean darkBackground) {
- toggleWhisperInfo(viewHolder, message, darkBackground);
- viewHolder.image.setVisibility(View.GONE);
- viewHolder.download_button.setVisibility(View.GONE);
- final RelativeLayout audioPlayer = viewHolder.audioPlayer;
- audioPlayer.setVisibility(View.VISIBLE);
- AudioPlayer.ViewHolder.get(audioPlayer).setDarkBackground(darkBackground);
- this.audioPlayer.init(audioPlayer, message);
- }
+ private void toggleWhisperInfo(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
+ if (message.isPrivateMessage()) {
+ final String privateMarker;
+ if (message.getStatus() <= Message.STATUS_RECEIVED) {
+ privateMarker = activity.getString(R.string.private_message);
+ } else {
+ Jid cp = message.getCounterpart();
+ privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
+ }
+ final SpannableString body = new SpannableString(privateMarker);
+ body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ viewHolder.messageBody.setText(body);
+ viewHolder.messageBody.setVisibility(View.VISIBLE);
+ } else {
+ viewHolder.messageBody.setVisibility(View.GONE);
+ }
+ }
- private void displayMediaPreviewMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
- toggleWhisperInfo(viewHolder, message, darkBackground);
- viewHolder.download_button.setVisibility(View.GONE);
- viewHolder.audioPlayer.setVisibility(View.GONE);
- viewHolder.image.setVisibility(View.VISIBLE);
- final FileParams params = message.getFileParams();
- final double target = metrics.density * 288;
- final int scaledW;
- final int scaledH;
- if (Math.max(params.height, params.width) * metrics.density <= target) {
- scaledW = (int) (params.width * metrics.density);
- scaledH = (int) (params.height * metrics.density);
- } else if (Math.max(params.height, params.width) <= target) {
- scaledW = params.width;
- scaledH = params.height;
- } else if (params.width <= params.height) {
- scaledW = (int) (params.width / ((double) params.height / target));
- scaledH = (int) target;
- } else {
- scaledW = (int) target;
- scaledH = (int) (params.height / ((double) params.width / target));
- }
- LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(scaledW, scaledH);
- layoutParams.setMargins(0, (int) (metrics.density * 4), 0, (int) (metrics.density * 4));
- viewHolder.image.setLayoutParams(layoutParams);
- activity.loadBitmap(message, viewHolder.image);
- viewHolder.image.setOnClickListener(v -> openDownloadable(message));
- }
+ private void loadMoreMessages(Conversation conversation) {
+ conversation.setLastClearHistory(0, null);
+ activity.xmppConnectionService.updateConversation(conversation);
+ conversation.setHasMessagesLeftOnServer(true);
+ conversation.setFirstMamReference(null);
+ long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
+ if (timestamp == 0) {
+ timestamp = System.currentTimeMillis();
+ }
+ conversation.messagesLoaded.set(true);
+ MessageArchiveService.Query query = activity.xmppConnectionService.getMessageArchiveService().query(conversation, new MamReference(0), timestamp, false);
+ if (query != null) {
+ Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG).show();
+ } else {
+ Toast.makeText(activity, R.string.not_fetching_history_retention_period, Toast.LENGTH_SHORT).show();
+ }
+ }
- private void toggleWhisperInfo(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
- if (message.isPrivateMessage()) {
- final String privateMarker;
- if (message.getStatus() <= Message.STATUS_RECEIVED) {
- privateMarker = activity.getString(R.string.private_message);
- } else {
- Jid cp = message.getCounterpart();
- privateMarker = activity.getString(R.string.private_message_to, Strings.nullToEmpty(cp == null ? null : cp.getResource()));
- }
- final SpannableString body = new SpannableString(privateMarker);
- body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- body.setSpan(new StyleSpan(Typeface.BOLD), 0, privateMarker.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- viewHolder.messageBody.setText(body);
- viewHolder.messageBody.setVisibility(View.VISIBLE);
- } else {
- viewHolder.messageBody.setVisibility(View.GONE);
- }
- }
-
- private void loadMoreMessages(Conversation conversation) {
- conversation.setLastClearHistory(0, null);
- activity.xmppConnectionService.updateConversation(conversation);
- conversation.setHasMessagesLeftOnServer(true);
- conversation.setFirstMamReference(null);
- long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
- if (timestamp == 0) {
- timestamp = System.currentTimeMillis();
- }
- conversation.messagesLoaded.set(true);
- MessageArchiveService.Query query = activity.xmppConnectionService.getMessageArchiveService().query(conversation, new MamReference(0), timestamp, false);
- if (query != null) {
- Toast.makeText(activity, R.string.fetching_history_from_server, Toast.LENGTH_LONG).show();
- } else {
- Toast.makeText(activity, R.string.not_fetching_history_retention_period, Toast.LENGTH_SHORT).show();
- }
- }
-
- @Override
- public View getView(int position, View view, ViewGroup parent) {
- final Message message = getItem(position);
- final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
- final boolean isInValidSession = message.isValidInSession() && (!omemoEncryption || message.isTrusted());
- final Conversational conversation = message.getConversation();
- final Account account = conversation.getAccount();
- final int type = getItemViewType(position);
- ViewHolder viewHolder;
- if (view == null) {
- viewHolder = new ViewHolder();
- switch (type) {
- case DATE_SEPARATOR:
- view = activity.getLayoutInflater().inflate(R.layout.message_date_bubble, parent, false);
+ @Override
+ public View getView(int position, View view, ViewGroup parent) {
+ final Message message = getItem(position);
+ final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
+ final boolean isInValidSession = message.isValidInSession() && (!omemoEncryption || message.isTrusted());
+ final Conversational conversation = message.getConversation();
+ final Account account = conversation.getAccount();
+ final int type = getItemViewType(position);
+ ViewHolder viewHolder;
+ if (view == null) {
+ viewHolder = new ViewHolder();
+ switch (type) {
+ case DATE_SEPARATOR:
+ view = activity.getLayoutInflater().inflate(R.layout.message_date_bubble, parent, false);
+ viewHolder.status_message = view.findViewById(R.id.message_body);
+ viewHolder.message_box = view.findViewById(R.id.message_box);
+ viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
+ break;
+ case RTP_SESSION:
+ view = activity.getLayoutInflater().inflate(R.layout.message_rtp_session, parent, false);
viewHolder.status_message = view.findViewById(R.id.message_body);
viewHolder.message_box = view.findViewById(R.id.message_box);
- break;
- case SENT:
- view = activity.getLayoutInflater().inflate(R.layout.message_sent, parent, false);
- viewHolder.message_box = view.findViewById(R.id.message_box);
- viewHolder.contact_picture = view.findViewById(R.id.message_photo);
- viewHolder.download_button = view.findViewById(R.id.download_button);
- viewHolder.indicator = view.findViewById(R.id.security_indicator);
- viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
- viewHolder.image = view.findViewById(R.id.message_image);
- viewHolder.messageBody = view.findViewById(R.id.message_body);
- viewHolder.time = view.findViewById(R.id.message_time);
viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
- viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
break;
- case RECEIVED:
- view = activity.getLayoutInflater().inflate(R.layout.message_received, parent, false);
- viewHolder.message_box = view.findViewById(R.id.message_box);
- viewHolder.contact_picture = view.findViewById(R.id.message_photo);
- viewHolder.download_button = view.findViewById(R.id.download_button);
- viewHolder.indicator = view.findViewById(R.id.security_indicator);
- viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
- viewHolder.image = view.findViewById(R.id.message_image);
- viewHolder.messageBody = view.findViewById(R.id.message_body);
- viewHolder.time = view.findViewById(R.id.message_time);
- viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
- viewHolder.encryption = view.findViewById(R.id.message_encryption);
- viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
- break;
- case STATUS:
- view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);
- viewHolder.contact_picture = view.findViewById(R.id.message_photo);
- viewHolder.status_message = view.findViewById(R.id.status_message);
- viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
- break;
- default:
- throw new AssertionError("Unknown view type");
- }
- if (viewHolder.messageBody != null) {
- listSelectionManager.onCreate(viewHolder.messageBody,
- new MessageBodyActionModeCallback(viewHolder.messageBody));
- viewHolder.messageBody.setCopyHandler(this);
- }
- view.setTag(viewHolder);
- } else {
- viewHolder = (ViewHolder) view.getTag();
- if (viewHolder == null) {
- return view;
- }
- }
+ case SENT:
+ view = activity.getLayoutInflater().inflate(R.layout.message_sent, parent, false);
+ viewHolder.message_box = view.findViewById(R.id.message_box);
+ viewHolder.contact_picture = view.findViewById(R.id.message_photo);
+ viewHolder.download_button = view.findViewById(R.id.download_button);
+ viewHolder.indicator = view.findViewById(R.id.security_indicator);
+ viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
+ viewHolder.image = view.findViewById(R.id.message_image);
+ viewHolder.messageBody = view.findViewById(R.id.message_body);
+ viewHolder.time = view.findViewById(R.id.message_time);
+ viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
+ viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+ break;
+ case RECEIVED:
+ view = activity.getLayoutInflater().inflate(R.layout.message_received, parent, false);
+ viewHolder.message_box = view.findViewById(R.id.message_box);
+ viewHolder.contact_picture = view.findViewById(R.id.message_photo);
+ viewHolder.download_button = view.findViewById(R.id.download_button);
+ viewHolder.indicator = view.findViewById(R.id.security_indicator);
+ viewHolder.edit_indicator = view.findViewById(R.id.edit_indicator);
+ viewHolder.image = view.findViewById(R.id.message_image);
+ viewHolder.messageBody = view.findViewById(R.id.message_body);
+ viewHolder.time = view.findViewById(R.id.message_time);
+ viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
+ viewHolder.encryption = view.findViewById(R.id.message_encryption);
+ viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+ break;
+ case STATUS:
+ view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);
+ viewHolder.contact_picture = view.findViewById(R.id.message_photo);
+ viewHolder.status_message = view.findViewById(R.id.status_message);
+ viewHolder.load_more_messages = view.findViewById(R.id.load_more_messages);
+ break;
+ default:
+ throw new AssertionError("Unknown view type");
+ }
+ if (viewHolder.messageBody != null) {
+ listSelectionManager.onCreate(viewHolder.messageBody,
+ new MessageBodyActionModeCallback(viewHolder.messageBody));
+ viewHolder.messageBody.setCopyHandler(this);
+ }
+ view.setTag(viewHolder);
+ } else {
+ viewHolder = (ViewHolder) view.getTag();
+ if (viewHolder == null) {
+ return view;
+ }
+ }
- boolean darkBackground = type == RECEIVED && !isInValidSession || activity.isDarkTheme();
+ boolean darkBackground = type == RECEIVED && !isInValidSession || activity.isDarkTheme();
- if (type == DATE_SEPARATOR) {
- if (UIHelper.today(message.getTimeSent())) {
- viewHolder.status_message.setText(R.string.today);
- } else if (UIHelper.yesterday(message.getTimeSent())) {
- viewHolder.status_message.setText(R.string.yesterday);
- } else {
- viewHolder.status_message.setText(DateUtils.formatDateTime(activity, message.getTimeSent(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
- }
- viewHolder.message_box.setBackgroundResource(activity.isDarkTheme() ? R.drawable.date_bubble_grey : R.drawable.date_bubble_white);
- return view;
- } else if (type == STATUS) {
- if ("LOAD_MORE".equals(message.getBody())) {
- viewHolder.status_message.setVisibility(View.GONE);
- viewHolder.contact_picture.setVisibility(View.GONE);
- viewHolder.load_more_messages.setVisibility(View.VISIBLE);
- viewHolder.load_more_messages.setOnClickListener(v -> loadMoreMessages((Conversation) message.getConversation()));
- } else {
- viewHolder.status_message.setVisibility(View.VISIBLE);
- viewHolder.load_more_messages.setVisibility(View.GONE);
- viewHolder.status_message.setText(message.getBody());
- boolean showAvatar;
- if (conversation.getMode() == Conversation.MODE_SINGLE) {
- showAvatar = true;
- AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
- } else if (message.getCounterpart() != null || message.getTrueCounterpart() != null || (message.getCounterparts() != null && message.getCounterparts().size() > 0)) {
- showAvatar = true;
- AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
- } else {
- showAvatar = false;
- }
- if (showAvatar) {
- viewHolder.contact_picture.setAlpha(0.5f);
- viewHolder.contact_picture.setVisibility(View.VISIBLE);
- } else {
- viewHolder.contact_picture.setVisibility(View.GONE);
- }
- }
- return view;
- } else {
- AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
- }
+ if (type == DATE_SEPARATOR) {
+ if (UIHelper.today(message.getTimeSent())) {
+ viewHolder.status_message.setText(R.string.today);
+ } else if (UIHelper.yesterday(message.getTimeSent())) {
+ viewHolder.status_message.setText(R.string.yesterday);
+ } else {
+ viewHolder.status_message.setText(DateUtils.formatDateTime(activity, message.getTimeSent(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR));
+ }
+ viewHolder.message_box.setBackgroundResource(activity.isDarkTheme() ? R.drawable.date_bubble_grey : R.drawable.date_bubble_white);
+ return view;
+ } else if (type == RTP_SESSION) {
+ final boolean isDarkTheme = activity.isDarkTheme();
+ final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
+ final RtpSessionStatus rtpSessionStatus = RtpSessionStatus.of(message.getBody());
+ final long duration = rtpSessionStatus.duration;
+ if (received) {
+ if (duration > 0) {
+ viewHolder.status_message.setText(activity.getString(R.string.incoming_call_duration, TimeframeUtils.resolve(activity,duration)));
+ } else {
+ viewHolder.status_message.setText(R.string.incoming_call);
+ }
+ } else {
+ if (duration > 0) {
+ viewHolder.status_message.setText(activity.getString(R.string.outgoing_call_duration, TimeframeUtils.resolve(activity,duration)));
+ } else {
+ viewHolder.status_message.setText(R.string.outgoing_call);
+ }
+ }
+ viewHolder.indicatorReceived.setImageResource(RtpSessionStatus.getDrawable(received,rtpSessionStatus.successful,isDarkTheme));
+ viewHolder.indicatorReceived.setAlpha(isDarkTheme ? 0.7f : 0.57f);
+ viewHolder.message_box.setBackgroundResource(isDarkTheme ? R.drawable.date_bubble_grey : R.drawable.date_bubble_white);
+ return view;
+ } else if (type == STATUS) {
+ if ("LOAD_MORE".equals(message.getBody())) {
+ viewHolder.status_message.setVisibility(View.GONE);
+ viewHolder.contact_picture.setVisibility(View.GONE);
+ viewHolder.load_more_messages.setVisibility(View.VISIBLE);
+ viewHolder.load_more_messages.setOnClickListener(v -> loadMoreMessages((Conversation) message.getConversation()));
+ } else {
+ viewHolder.status_message.setVisibility(View.VISIBLE);
+ viewHolder.load_more_messages.setVisibility(View.GONE);
+ viewHolder.status_message.setText(message.getBody());
+ boolean showAvatar;
+ if (conversation.getMode() == Conversation.MODE_SINGLE) {
+ showAvatar = true;
+ AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
+ } else if (message.getCounterpart() != null || message.getTrueCounterpart() != null || (message.getCounterparts() != null && message.getCounterparts().size() > 0)) {
+ showAvatar = true;
+ AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
+ } else {
+ showAvatar = false;
+ }
+ if (showAvatar) {
+ viewHolder.contact_picture.setAlpha(0.5f);
+ viewHolder.contact_picture.setVisibility(View.VISIBLE);
+ } else {
+ viewHolder.contact_picture.setVisibility(View.GONE);
+ }
+ }
+ return view;
+ } else {
+ AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
+ }
- resetClickListener(viewHolder.message_box, viewHolder.messageBody);
+ resetClickListener(viewHolder.message_box, viewHolder.messageBody);
- viewHolder.contact_picture.setOnClickListener(v -> {
- if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
- MessageAdapter.this.mOnContactPictureClickedListener
- .onContactPictureClicked(message);
- }
+ viewHolder.contact_picture.setOnClickListener(v -> {
+ if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
+ MessageAdapter.this.mOnContactPictureClickedListener
+ .onContactPictureClicked(message);
+ }
- });
- viewHolder.contact_picture.setOnLongClickListener(v -> {
- if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
- MessageAdapter.this.mOnContactPictureLongClickedListener
- .onContactPictureLongClicked(v, message);
- return true;
- } else {
- return false;
- }
- });
+ });
+ viewHolder.contact_picture.setOnLongClickListener(v -> {
+ if (MessageAdapter.this.mOnContactPictureLongClickedListener != null) {
+ MessageAdapter.this.mOnContactPictureLongClickedListener
+ .onContactPictureLongClicked(v, message);
+ return true;
+ } else {
+ return false;
+ }
+ });
- final Transferable transferable = message.getTransferable();
- final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
- if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
- if (unInitiatedButKnownSize || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
- displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
- } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
- displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
- } else {
- displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity, message).first, darkBackground);
- }
- } else if (message.isFileOrImage() && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
- if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
- displayMediaPreviewMessage(viewHolder, message, darkBackground);
- } else if (message.getFileParams().runtime > 0) {
- displayAudioMessage(viewHolder, message, darkBackground);
- } else {
- displayOpenableMessage(viewHolder, message, darkBackground);
- }
- } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
- if (account.isPgpDecryptionServiceConnected()) {
- if (conversation instanceof Conversation && !account.hasPendingPgpIntent((Conversation) conversation)) {
- displayInfoMessage(viewHolder, activity.getString(R.string.message_decrypting), darkBackground);
- } else {
- displayInfoMessage(viewHolder, activity.getString(R.string.pgp_message), darkBackground);
- }
- } else {
- displayInfoMessage(viewHolder, activity.getString(R.string.install_openkeychain), darkBackground);
- viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
- viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
- }
- } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
- displayInfoMessage(viewHolder, activity.getString(R.string.decryption_failed), darkBackground);
- } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
- displayInfoMessage(viewHolder, activity.getString(R.string.not_encrypted_for_this_device), darkBackground);
- } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
- displayInfoMessage(viewHolder, activity.getString(R.string.omemo_decryption_failed), darkBackground);
- } else {
- if (message.isGeoUri()) {
- displayLocationMessage(viewHolder, message, darkBackground);
- } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
- displayEmojiMessage(viewHolder, message.getBody().trim(), darkBackground);
- } else if (message.treatAsDownloadable()) {
- try {
- URL url = new URL(message.getBody());
- if (P1S3UrlStreamHandler.PROTOCOL_NAME.equalsIgnoreCase(url.getProtocol())) {
- displayDownloadableMessage(viewHolder,
- message,
- activity.getString(R.string.check_x_filesize,
- UIHelper.getFileDescriptionString(activity, message)),
- darkBackground);
- } else {
- displayDownloadableMessage(viewHolder,
- message,
- activity.getString(R.string.check_x_filesize_on_host,
- UIHelper.getFileDescriptionString(activity, message),
- url.getHost()),
- darkBackground);
- }
- } catch (Exception e) {
- displayDownloadableMessage(viewHolder,
- message,
- activity.getString(R.string.check_x_filesize,
- UIHelper.getFileDescriptionString(activity, message)),
- darkBackground);
- }
- } else {
- displayTextMessage(viewHolder, message, darkBackground, type);
- }
- }
+ final Transferable transferable = message.getTransferable();
+ final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
+ if (unInitiatedButKnownSize || message.isDeleted() || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING)) {
+ if (unInitiatedButKnownSize || transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER) {
+ displayDownloadableMessage(viewHolder, message, activity.getString(R.string.download_x_file, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
+ } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
+ displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), darkBackground);
+ } else {
+ displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity, message).first, darkBackground);
+ }
+ } else if (message.isFileOrImage() && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
+ if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {
+ displayMediaPreviewMessage(viewHolder, message, darkBackground);
+ } else if (message.getFileParams().runtime > 0) {
+ displayAudioMessage(viewHolder, message, darkBackground);
+ } else {
+ displayOpenableMessage(viewHolder, message, darkBackground);
+ }
+ } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
+ if (account.isPgpDecryptionServiceConnected()) {
+ if (conversation instanceof Conversation && !account.hasPendingPgpIntent((Conversation) conversation)) {
+ displayInfoMessage(viewHolder, activity.getString(R.string.message_decrypting), darkBackground);
+ } else {
+ displayInfoMessage(viewHolder, activity.getString(R.string.pgp_message), darkBackground);
+ }
+ } else {
+ displayInfoMessage(viewHolder, activity.getString(R.string.install_openkeychain), darkBackground);
+ viewHolder.message_box.setOnClickListener(this::promptOpenKeychainInstall);
+ viewHolder.messageBody.setOnClickListener(this::promptOpenKeychainInstall);
+ }
+ } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) {
+ displayInfoMessage(viewHolder, activity.getString(R.string.decryption_failed), darkBackground);
+ } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) {
+ displayInfoMessage(viewHolder, activity.getString(R.string.not_encrypted_for_this_device), darkBackground);
+ } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_FAILED) {
+ displayInfoMessage(viewHolder, activity.getString(R.string.omemo_decryption_failed), darkBackground);
+ } else {
+ if (message.isGeoUri()) {
+ displayLocationMessage(viewHolder, message, darkBackground);
+ } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
+ displayEmojiMessage(viewHolder, message.getBody().trim(), darkBackground);
+ } else if (message.treatAsDownloadable()) {
+ try {
+ URL url = new URL(message.getBody());
+ if (P1S3UrlStreamHandler.PROTOCOL_NAME.equalsIgnoreCase(url.getProtocol())) {
+ displayDownloadableMessage(viewHolder,
+ message,
+ activity.getString(R.string.check_x_filesize,
+ UIHelper.getFileDescriptionString(activity, message)),
+ darkBackground);
+ } else {
+ displayDownloadableMessage(viewHolder,
+ message,
+ activity.getString(R.string.check_x_filesize_on_host,
+ UIHelper.getFileDescriptionString(activity, message),
+ url.getHost()),
+ darkBackground);
+ }
+ } catch (Exception e) {
+ displayDownloadableMessage(viewHolder,
+ message,
+ activity.getString(R.string.check_x_filesize,
+ UIHelper.getFileDescriptionString(activity, message)),
+ darkBackground);
+ }
+ } else {
+ displayTextMessage(viewHolder, message, darkBackground, type);
+ }
+ }
- if (type == RECEIVED) {
- if (isInValidSession) {
- viewHolder.message_box.setBackgroundResource(activity.getThemeResource(R.attr.message_bubble_received_monochrome, R.drawable.message_bubble_received_white));
- viewHolder.encryption.setVisibility(View.GONE);
- } else {
- viewHolder.message_box.setBackgroundResource(R.drawable.message_bubble_received_warning);
- viewHolder.encryption.setVisibility(View.VISIBLE);
- if (omemoEncryption && !message.isTrusted()) {
- viewHolder.encryption.setText(R.string.not_trusted);
- } else {
- viewHolder.encryption.setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
- }
- }
- }
+ if (type == RECEIVED) {
+ if (isInValidSession) {
+ viewHolder.message_box.setBackgroundResource(activity.getThemeResource(R.attr.message_bubble_received_monochrome, R.drawable.message_bubble_received_white));
+ viewHolder.encryption.setVisibility(View.GONE);
+ } else {
+ viewHolder.message_box.setBackgroundResource(R.drawable.message_bubble_received_warning);
+ viewHolder.encryption.setVisibility(View.VISIBLE);
+ if (omemoEncryption && !message.isTrusted()) {
+ viewHolder.encryption.setText(R.string.not_trusted);
+ } else {
+ viewHolder.encryption.setText(CryptoHelper.encryptionTypeToText(message.getEncryption()));
+ }
+ }
+ }
- displayStatus(viewHolder, message, type, darkBackground);
+ displayStatus(viewHolder, message, type, darkBackground);
- return view;
- }
+ return view;
+ }
- private void promptOpenKeychainInstall(View view) {
- activity.showInstallPgpDialog();
- }
+ private void promptOpenKeychainInstall(View view) {
+ activity.showInstallPgpDialog();
+ }
- @Override
- public void notifyDataSetChanged() {
- listSelectionManager.onBeforeNotifyDataSetChanged();
- super.notifyDataSetChanged();
- listSelectionManager.onAfterNotifyDataSetChanged();
- }
+ @Override
+ public void notifyDataSetChanged() {
+ listSelectionManager.onBeforeNotifyDataSetChanged();
+ super.notifyDataSetChanged();
+ listSelectionManager.onAfterNotifyDataSetChanged();
+ }
- private String transformText(CharSequence text, int start, int end, boolean forCopy) {
- SpannableStringBuilder builder = new SpannableStringBuilder(text);
- Object copySpan = new Object();
- builder.setSpan(copySpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
- DividerSpan[] dividerSpans = builder.getSpans(0, builder.length(), DividerSpan.class);
- for (DividerSpan dividerSpan : dividerSpans) {
- builder.replace(builder.getSpanStart(dividerSpan), builder.getSpanEnd(dividerSpan),
- dividerSpan.isLarge() ? "\n\n" : "\n");
- }
- start = builder.getSpanStart(copySpan);
- end = builder.getSpanEnd(copySpan);
- if (start == -1 || end == -1) return "";
- builder = new SpannableStringBuilder(builder, start, end);
- if (forCopy) {
- QuoteSpan[] quoteSpans = builder.getSpans(0, builder.length(), QuoteSpan.class);
- for (QuoteSpan quoteSpan : quoteSpans) {
- builder.insert(builder.getSpanStart(quoteSpan), "> ");
- }
- }
- return builder.toString();
- }
+ private String transformText(CharSequence text, int start, int end, boolean forCopy) {
+ SpannableStringBuilder builder = new SpannableStringBuilder(text);
+ Object copySpan = new Object();
+ builder.setSpan(copySpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ DividerSpan[] dividerSpans = builder.getSpans(0, builder.length(), DividerSpan.class);
+ for (DividerSpan dividerSpan : dividerSpans) {
+ builder.replace(builder.getSpanStart(dividerSpan), builder.getSpanEnd(dividerSpan),
+ dividerSpan.isLarge() ? "\n\n" : "\n");
+ }
+ start = builder.getSpanStart(copySpan);
+ end = builder.getSpanEnd(copySpan);
+ if (start == -1 || end == -1) return "";
+ builder = new SpannableStringBuilder(builder, start, end);
+ if (forCopy) {
+ QuoteSpan[] quoteSpans = builder.getSpans(0, builder.length(), QuoteSpan.class);
+ for (QuoteSpan quoteSpan : quoteSpans) {
+ builder.insert(builder.getSpanStart(quoteSpan), "> ");
+ }
+ }
+ return builder.toString();
+ }
- @Override
- public String transformTextForCopy(CharSequence text, int start, int end) {
- if (text instanceof Spanned) {
- return transformText(text, start, end, true);
- } else {
- return text.toString().substring(start, end);
- }
- }
+ @Override
+ public String transformTextForCopy(CharSequence text, int start, int end) {
+ if (text instanceof Spanned) {
+ return transformText(text, start, end, true);
+ } else {
+ return text.toString().substring(start, end);
+ }
+ }
- public FileBackend getFileBackend() {
- return activity.xmppConnectionService.getFileBackend();
- }
+ public FileBackend getFileBackend() {
+ return activity.xmppConnectionService.getFileBackend();
+ }
- public void stopAudioPlayer() {
- audioPlayer.stop();
- }
+ public void stopAudioPlayer() {
+ audioPlayer.stop();
+ }
- public void unregisterListenerInAudioPlayer() {
- audioPlayer.unregisterListener();
- }
+ public void unregisterListenerInAudioPlayer() {
+ audioPlayer.unregisterListener();
+ }
- public void startStopPending() {
- audioPlayer.startStopPending();
- }
+ public void startStopPending() {
+ audioPlayer.startStopPending();
+ }
- public void openDownloadable(Message message) {
- if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
- ConversationFragment.registerPendingMessage(activity, message);
- ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ConversationsActivity.REQUEST_OPEN_MESSAGE);
- return;
- }
- final DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
- ViewUtil.view(activity, file);
- }
+ public void openDownloadable(Message message) {
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ ConversationFragment.registerPendingMessage(activity, message);
+ ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, ConversationsActivity.REQUEST_OPEN_MESSAGE);
+ return;
+ }
+ final DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
+ ViewUtil.view(activity, file);
+ }
- private void showLocation(Message message) {
- for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
- if (intent.resolveActivity(getContext().getPackageManager()) != null) {
- getContext().startActivity(intent);
- return;
- }
- }
- Toast.makeText(activity, R.string.no_application_found_to_display_location, Toast.LENGTH_SHORT).show();
- }
+ private void showLocation(Message message) {
+ for (Intent intent : GeoHelper.createGeoIntentsFromMessage(activity, message)) {
+ if (intent.resolveActivity(getContext().getPackageManager()) != null) {
+ getContext().startActivity(intent);
+ return;
+ }
+ }
+ Toast.makeText(activity, R.string.no_application_found_to_display_location, Toast.LENGTH_SHORT).show();
+ }
- public void updatePreferences() {
- SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
- }
+ public void setHighlightedTerm(List terms) {
+ this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
+ }
+
+ public interface OnQuoteListener {
+ void onQuote(String text);
+ }
+
+ public interface OnContactPictureClicked {
+ void onContactPictureClicked(Message message);
+ }
+
+ public interface OnContactPictureLongClicked {
+ void onContactPictureLongClicked(View v, Message message);
+ }
+
+ private static class ViewHolder {
+
+ public Button load_more_messages;
+ public ImageView edit_indicator;
+ public RelativeLayout audioPlayer;
+ protected LinearLayout message_box;
+ protected Button download_button;
+ protected ImageView image;
+ protected ImageView indicator;
+ protected ImageView indicatorReceived;
+ protected TextView time;
+ protected CopyTextView messageBody;
+ protected ImageView contact_picture;
+ protected TextView status_message;
+ protected TextView encryption;
+ }
- public void setHighlightedTerm(List terms) {
- this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms);
- }
+ private class MessageBodyActionModeCallback implements ActionMode.Callback {
- public interface OnQuoteListener {
- void onQuote(String text);
- }
+ private final TextView textView;
- public interface OnContactPictureClicked {
- void onContactPictureClicked(Message message);
- }
+ public MessageBodyActionModeCallback(TextView textView) {
+ this.textView = textView;
+ }
- public interface OnContactPictureLongClicked {
- void onContactPictureLongClicked(View v, Message message);
- }
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ if (onQuoteListener != null) {
+ int quoteResId = activity.getThemeResource(R.attr.icon_quote, R.drawable.ic_action_reply);
+ // 3rd item is placed after "copy" item
+ menu.add(0, android.R.id.button1, 3, R.string.quote).setIcon(quoteResId)
+ .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT);
+ }
+ return false;
+ }
- private static class ViewHolder {
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
- public Button load_more_messages;
- public ImageView edit_indicator;
- public RelativeLayout audioPlayer;
- protected LinearLayout message_box;
- protected Button download_button;
- protected ImageView image;
- protected ImageView indicator;
- protected ImageView indicatorReceived;
- protected TextView time;
- protected CopyTextView messageBody;
- protected ImageView contact_picture;
- protected TextView status_message;
- protected TextView encryption;
- }
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ if (item.getItemId() == android.R.id.button1) {
+ int start = textView.getSelectionStart();
+ int end = textView.getSelectionEnd();
+ if (end > start) {
+ String text = transformText(textView.getText(), start, end, false);
+ if (onQuoteListener != null) {
+ onQuoteListener.onQuote(text);
+ }
+ mode.finish();
+ }
+ return true;
+ }
+ return false;
+ }
-
- private class MessageBodyActionModeCallback implements ActionMode.Callback {
-
- private final TextView textView;
-
- public MessageBodyActionModeCallback(TextView textView) {
- this.textView = textView;
- }
-
- @Override
- public boolean onCreateActionMode(ActionMode mode, Menu menu) {
- if (onQuoteListener != null) {
- int quoteResId = activity.getThemeResource(R.attr.icon_quote, R.drawable.ic_action_reply);
- // 3rd item is placed after "copy" item
- menu.add(0, android.R.id.button1, 3, R.string.quote).setIcon(quoteResId)
- .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT);
- }
- return false;
- }
-
- @Override
- public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
- return false;
- }
-
- @Override
- public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
- if (item.getItemId() == android.R.id.button1) {
- int start = textView.getSelectionStart();
- int end = textView.getSelectionEnd();
- if (end > start) {
- String text = transformText(textView.getText(), start, end, false);
- if (onQuoteListener != null) {
- onQuoteListener.onQuote(text);
- }
- mode.finish();
- }
- return true;
- }
- return false;
- }
-
- @Override
- public void onDestroyActionMode(ActionMode mode) {
- }
- }
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java b/src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java
new file mode 100644
index 000000000..1b6e43f75
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/utils/AppRTCUtils.java
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/utils/Compatibility.java b/src/main/java/eu/siacs/conversations/utils/Compatibility.java
index 9f7b9c997..13e38e487 100644
--- a/src/main/java/eu/siacs/conversations/utils/Compatibility.java
+++ b/src/main/java/eu/siacs/conversations/utils/Compatibility.java
@@ -30,8 +30,13 @@ public class Compatibility {
"led",
"notification_ringtone",
"notification_headsup",
- "vibrate_on_notification");
- private static final List UNUESD_SETTINGS_PRE_TWENTYSIX = Collections.singletonList("more_notification_settings");
+ "vibrate_on_notification",
+ "call_ringtone"
+ );
+ private static final List 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");
}
}
}
diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java
index 3040ce15e..e26010d69 100644
--- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java
+++ b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java
@@ -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");
diff --git a/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java b/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java
index 706b6c2f8..852dedc00 100644
--- a/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java
+++ b/src/main/java/eu/siacs/conversations/utils/PermissionUtils.java
@@ -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 permissions, final int requestCode) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ final ImmutableList.Builder missingPermissions = new ImmutableList.Builder<>();
+ for (final String permission : permissions) {
+ if (ActivityCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED) {
+ missingPermissions.add(permission);
+ }
+ }
+ final ImmutableList missing = missingPermissions.build();
+ if (missing.size() == 0) {
+ return true;
+ }
+ ActivityCompat.requestPermissions(activity, missing.toArray(new String[0]), requestCode);
+ return false;
+ } else {
+ return true;
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java
index 47fec58c6..30a62bedf 100644
--- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java
+++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java
@@ -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)) {
diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java
index 921a2f580..31b3420dd 100644
--- a/src/main/java/eu/siacs/conversations/xml/Namespace.java
+++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java
@@ -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";
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java
index b9868cb7f..fc01150b8 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java
@@ -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 packetCallbackDuple = packetCallbacks.get(packet.getId());
+ final Pair 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);
+ }
}
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java
new file mode 100644
index 000000000..088b4fc17
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java
@@ -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
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java
index 7415c32aa..e1f4db4b0 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java
@@ -101,22 +101,24 @@ public class JingleCandidate {
return this.type;
}
- public static List parse(List canditates) {
- List parsedCandidates = new ArrayList<>();
- for (Element c : canditates) {
- parsedCandidates.add(JingleCandidate.parse(c));
+ public static List parse(final List elements) {
+ final List 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() {
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java
index 60d6ebfe2..eff4f7edb 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java
@@ -1,88 +1,360 @@
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 connections = new CopyOnWriteArrayList<>();
+ private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
+ private final HashMap rtpSessionProposals = new HashMap<>();
+ private final Map connections = new ConcurrentHashMap<>();
+
+ private final Cache endedSessions = CacheBuilder.newBuilder()
+ .expireAfterWrite(30, TimeUnit.MINUTES)
+ .build();
private HashMap 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) {
+ 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();
+ }
+
+ public ScheduledFuture> schedule(final Runnable runnable, final long delay, final TimeUnit timeUnit) {
+ return this.scheduledExecutorService.schedule(runnable, delay, timeUnit);
+ }
+
+ public 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 descriptions = propose.getDescriptions();
+ final Collection 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 = 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 +369,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 +384,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 +400,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) {
+ synchronized (this.rtpSessionProposals) {
+ for (Map.Entry 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 +488,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 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;
+ 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) {
+ this.account = account;
+ this.with = with;
+ this.sessionId = sessionId;
+ this.media = media;
+ }
+
+ public static RtpSessionProposal of(Account account, Jid with, Set 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);
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java
similarity index 60%
rename from src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java
rename to src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java
index 3b2909cc7..f0941d27c 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnection.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java
@@ -3,6 +3,10 @@ package eu.siacs.conversations.xmpp.jingle;
import android.util.Base64;
import android.util.Log;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Collections2;
+
+import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -10,6 +14,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
@@ -30,52 +35,50 @@ import eu.siacs.conversations.entities.TransferablePlaceholder;
import eu.siacs.conversations.parser.IqParser;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.AbstractConnectionManager;
-import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.CryptoHelper;
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.GenericTransportInfo;
+import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
+import eu.siacs.conversations.xmpp.jingle.stanzas.S5BTransportInfo;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import rocks.xmpp.addr.Jid;
-public class JingleConnection implements Transferable {
+public class JingleFileTransferConnection extends AbstractJingleConnection implements Transferable {
+ private static final int JINGLE_STATUS_TRANSMITTING = 5;
private static final String JET_OMEMO_CIPHER = "urn:xmpp:ciphers:aes-128-gcm-nopadding";
-
private static final int JINGLE_STATUS_INITIATED = 0;
private static final int JINGLE_STATUS_ACCEPTED = 1;
private static final int JINGLE_STATUS_FINISHED = 4;
- static final int JINGLE_STATUS_TRANSMITTING = 5;
private static final int JINGLE_STATUS_FAILED = 99;
private static final int JINGLE_STATUS_OFFERED = -1;
- private JingleConnectionManager mJingleConnectionManager;
- private XmppConnectionService mXmppConnectionService;
- private Content.Version ftVersion = Content.Version.FT_3;
- private int ibbBlockSize = 8192;
+ private static final int MAX_IBB_BLOCK_SIZE = 8192;
- private int mJingleStatus = JINGLE_STATUS_OFFERED;
+ private int ibbBlockSize = MAX_IBB_BLOCK_SIZE;
+
+ private int mJingleStatus = JINGLE_STATUS_OFFERED; //migrate to enum
private int mStatus = Transferable.STATUS_UNKNOWN;
private Message message;
- private String sessionId;
- private Account account;
- private Jid initiator;
private Jid responder;
private List candidates = new ArrayList<>();
private ConcurrentHashMap connections = new ConcurrentHashMap<>();
private String transportId;
- private Element fileOffer;
+ private FileTransferDescription description;
private DownloadableFile file = null;
private boolean proxyActivationFailed = false;
private String contentName;
- private String contentCreator;
- private Transport initialTransport;
+ private Content.Creator contentCreator;
+ private Class extends GenericTransportInfo> initialTransport;
private boolean remoteSupportsOmemoJet;
private int mProgress = 0;
@@ -98,7 +101,7 @@ public class JingleConnection implements Transferable {
if (mJingleStatus != JINGLE_STATUS_FAILED && mJingleStatus != JINGLE_STATUS_FINISHED) {
fail(IqParser.extractErrorMessage(packet));
} else {
- Log.d(Config.LOGTAG,"ignoring late delivery of jingle packet to jingle session with status="+mJingleStatus+": "+packet.toString());
+ Log.d(Config.LOGTAG, "ignoring late delivery of jingle packet to jingle session with status=" + mJingleStatus + ": " + packet.toString());
}
}
};
@@ -108,33 +111,38 @@ public class JingleConnection implements Transferable {
@Override
public void onFileTransmitted(DownloadableFile file) {
if (responding()) {
- if (expectedHash.length > 0 && !Arrays.equals(expectedHash, file.getSha1Sum())) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": hashes did not match");
+ if (expectedHash.length > 0) {
+ if (Arrays.equals(expectedHash, file.getSha1Sum())) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received file matched the expected hash");
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": hashes did not match");
+ }
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party did not include file hash in file transfer");
}
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": file transmitted(). we are responding");
sendSuccess();
- mXmppConnectionService.getFileBackend().updateFileParams(message);
- mXmppConnectionService.databaseBackend.createMessage(message);
- mXmppConnectionService.markMessage(message, Message.STATUS_RECEIVED);
+ xmppConnectionService.getFileBackend().updateFileParams(message);
+ xmppConnectionService.databaseBackend.createMessage(message);
+ xmppConnectionService.markMessage(message, Message.STATUS_RECEIVED);
if (acceptedAutomatically) {
message.markUnread();
if (message.getEncryption() == Message.ENCRYPTION_PGP) {
- account.getPgpDecryptionService().decrypt(message, true);
+ id.account.getPgpDecryptionService().decrypt(message, true);
} else {
- mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> JingleConnection.this.mXmppConnectionService.getNotificationService().push(message));
+ xmppConnectionService.getFileBackend().updateMediaScanner(file, () -> JingleFileTransferConnection.this.xmppConnectionService.getNotificationService().push(message));
}
Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
return;
} else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
- account.getPgpDecryptionService().decrypt(message, true);
+ id.account.getPgpDecryptionService().decrypt(message, true);
}
} else {
- if (ftVersion == Content.Version.FT_5) { //older Conversations will break when receiving a session-info
+ if (description.getVersion() == FileTransferDescription.Version.FT_5) { //older Conversations will break when receiving a session-info
sendHash();
}
if (message.getEncryption() == Message.ENCRYPTION_PGP) {
- account.getPgpDecryptionService().decrypt(message, false);
+ id.account.getPgpDecryptionService().decrypt(message, false);
}
if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
file.delete();
@@ -142,14 +150,14 @@ public class JingleConnection implements Transferable {
}
Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")");
if (message.getEncryption() != Message.ENCRYPTION_PGP) {
- mXmppConnectionService.getFileBackend().updateMediaScanner(file);
+ xmppConnectionService.getFileBackend().updateMediaScanner(file);
}
}
@Override
public void onFileTransferAborted() {
- JingleConnection.this.sendSessionTerminate("connectivity-error");
- JingleConnection.this.fail();
+ JingleFileTransferConnection.this.sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
+ JingleFileTransferConnection.this.fail();
}
};
private OnTransportConnected onIbbTransportConnected = new OnTransportConnected() {
@@ -160,16 +168,16 @@ public class JingleConnection implements Transferable {
@Override
public void established() {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ibb transport connected. sending file");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ibb transport connected. sending file");
mJingleStatus = JINGLE_STATUS_TRANSMITTING;
- JingleConnection.this.transport.send(file, onFileTransmissionStatusChanged);
+ JingleFileTransferConnection.this.transport.send(file, onFileTransmissionStatusChanged);
}
};
private OnProxyActivated onProxyActivated = new OnProxyActivated() {
@Override
public void success() {
- if (initiator.equals(account.getJid())) {
+ if (isInitiator()) {
Log.d(Config.LOGTAG, "we were initiating. sending file");
transport.send(file, onFileTransmissionStatusChanged);
} else {
@@ -180,27 +188,35 @@ public class JingleConnection implements Transferable {
@Override
public void failed() {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": proxy activation failed");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": proxy activation failed");
proxyActivationFailed = true;
- if (initiating()) {
+ if (isInitiator()) {
sendFallbackToIbb();
}
}
};
- public JingleConnection(JingleConnectionManager mJingleConnectionManager) {
- this.mJingleConnectionManager = mJingleConnectionManager;
- this.mXmppConnectionService = mJingleConnectionManager
- .getXmppConnectionService();
+ public JingleFileTransferConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
+ super(jingleConnectionManager, id, initiator);
}
+ private static long parseLong(final Element element, final long l) {
+ final String input = element == null ? null : element.getContent();
+ if (input == null) {
+ return l;
+ }
+ try {
+ return Long.parseLong(input);
+ } catch (Exception e) {
+ return l;
+ }
+ }
+
+ //TODO get rid and use isInitiator() instead
private boolean responding() {
- return responder != null && responder.equals(account.getJid());
+ return responder != null && responder.equals(id.account.getJid());
}
- private boolean initiating() {
- return initiator.equals(account.getJid());
- }
InputStream getFileInputStream() {
return this.mFileInputStream;
@@ -211,48 +227,43 @@ public class JingleConnection implements Transferable {
Log.d(Config.LOGTAG, "file object was not assigned");
return null;
}
- this.file.getParentFile().mkdirs();
- this.file.createNewFile();
+ final File parent = this.file.getParentFile();
+ if (parent != null && parent.mkdirs()) {
+ Log.d(Config.LOGTAG, "created parent directories for file " + file.getAbsolutePath());
+ }
+ if (this.file.createNewFile()) {
+ Log.d(Config.LOGTAG, "created output file " + file.getAbsolutePath());
+ }
this.mFileOutputStream = AbstractConnectionManager.createOutputStream(this.file, false, true);
return this.mFileOutputStream;
}
- public String getSessionId() {
- return this.sessionId;
- }
-
- public Account getAccount() {
- return this.account;
- }
-
- public Jid getCounterPart() {
- return this.message.getCounterpart();
- }
-
- void deliverPacket(JinglePacket packet) {
- if (packet.isAction("session-terminate")) {
- Reason reason = packet.getReason();
- if (reason != null) {
- if (reason.hasChild("cancel")) {
+ @Override
+ void deliverPacket(final JinglePacket packet) {
+ final JinglePacket.Action action = packet.getAction();
+ //TODO switch case
+ if (action == JinglePacket.Action.SESSION_INITIATE) {
+ init(packet);
+ } else if (action == JinglePacket.Action.SESSION_TERMINATE) {
+ final Reason reason = packet.getReason().reason;
+ switch (reason) {
+ case CANCEL:
this.cancelled = true;
this.fail();
- } else if (reason.hasChild("success")) {
+ break;
+ case SUCCESS:
this.receiveSuccess();
- } else {
- final List children = reason.getChildren();
- if (children.size() == 1) {
- this.fail(children.get(0).getName());
- } else {
- this.fail();
- }
- }
- } else {
- this.fail();
+ break;
+ default:
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate with reason " + reason);
+ this.fail();
+ break;
+
}
- } else if (packet.isAction("session-accept")) {
+ } else if (action == JinglePacket.Action.SESSION_ACCEPT) {
receiveAccept(packet);
- } else if (packet.isAction("session-info")) {
- final Element checksum = packet.getChecksum();
+ } else if (action == JinglePacket.Action.SESSION_INFO) {
+ final Element checksum = packet.getJingleChild("checksum");
final Element file = checksum == null ? null : checksum.findChild("file");
final Element hash = file == null ? null : file.findChild("hash", "urn:xmpp:hashes:2");
if (hash != null && "sha-1".equalsIgnoreCase(hash.getAttribute("algo"))) {
@@ -263,16 +274,18 @@ public class JingleConnection implements Transferable {
}
}
respondToIq(packet, true);
- } else if (packet.isAction("transport-info")) {
+ } else if (action == JinglePacket.Action.TRANSPORT_INFO) {
receiveTransportInfo(packet);
- } else if (packet.isAction("transport-replace")) {
- if (packet.getJingleContent().hasIbbTransport()) {
- receiveFallbackToIbb(packet);
+ } else if (action == JinglePacket.Action.TRANSPORT_REPLACE) {
+ final Content content = packet.getJingleContent();
+ final GenericTransportInfo transportInfo = content == null ? null : content.getTransport();
+ if (transportInfo instanceof IbbTransportInfo) {
+ receiveFallbackToIbb(packet, (IbbTransportInfo) transportInfo);
} else {
Log.d(Config.LOGTAG, "trying to fallback to something unknown" + packet.toString());
respondToIq(packet, false);
}
- } else if (packet.isAction("transport-accept")) {
+ } else if (action == JinglePacket.Action.TRANSPORT_ACCEPT) {
receiveTransportAccept(packet);
} else {
Log.d(Config.LOGTAG, "packet arrived in connection. action was " + packet.getAction());
@@ -280,6 +293,13 @@ public class JingleConnection implements Transferable {
}
}
+ @Override
+ void notifyRebound() {
+ if (getJingleStatus() == JINGLE_STATUS_TRANSMITTING) {
+ abort(Reason.CONNECTIVITY_ERROR);
+ }
+ }
+
private void respondToIq(final IqPacket packet, final boolean result) {
final IqPacket response;
if (result) {
@@ -289,7 +309,7 @@ public class JingleConnection implements Transferable {
final Element error = response.addChild("error").setAttribute("type", "cancel");
error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas");
}
- mXmppConnectionService.sendIqPacket(account, response, null);
+ xmppConnectionService.sendIqPacket(id.account, response, null);
}
private void respondToIqWithOutOfOrder(final IqPacket packet) {
@@ -297,10 +317,11 @@ public class JingleConnection implements Transferable {
final Element error = response.addChild("error").setAttribute("type", "wait");
error.addChild("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas");
error.addChild("out-of-order", "urn:xmpp:jingle:errors:1");
- mXmppConnectionService.sendIqPacket(account, response, null);
+ xmppConnectionService.sendIqPacket(id.account, response, null);
}
public void init(final Message message) {
+ Preconditions.checkArgument(message.isFileOrImage());
if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
Conversation conversation = (Conversation) message.getConversation();
conversation.getAccount().getAxolotlService().prepareKeyTransportMessage(conversation, xmppAxolotlMessage -> {
@@ -315,27 +336,25 @@ public class JingleConnection implements Transferable {
}
}
- private void init(Message message, XmppAxolotlMessage xmppAxolotlMessage) {
+ private void init(final Message message, final XmppAxolotlMessage xmppAxolotlMessage) {
this.mXmppAxolotlMessage = xmppAxolotlMessage;
- this.contentCreator = "initiator";
- this.contentName = this.mJingleConnectionManager.nextRandomId();
+ this.contentCreator = Content.Creator.INITIATOR;
+ this.contentName = JingleConnectionManager.nextRandomId();
this.message = message;
- this.account = message.getConversation().getAccount();
final List remoteFeatures = getRemoteFeatures();
- upgradeNamespace(remoteFeatures);
- this.initialTransport = remoteFeatures.contains(Namespace.JINGLE_TRANSPORTS_S5B) ? Transport.SOCKS : Transport.IBB;
+ final FileTransferDescription.Version remoteVersion = getAvailableFileTransferVersion(remoteFeatures);
+ this.initialTransport = remoteFeatures.contains(Namespace.JINGLE_TRANSPORTS_S5B) ? S5BTransportInfo.class : IbbTransportInfo.class;
this.remoteSupportsOmemoJet = remoteFeatures.contains(Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO);
this.message.setTransferable(this);
this.mStatus = Transferable.STATUS_UPLOADING;
- this.initiator = this.account.getJid();
- this.responder = this.message.getCounterpart();
- this.sessionId = this.mJingleConnectionManager.nextRandomId();
- this.transportId = this.mJingleConnectionManager.nextRandomId();
- if (this.initialTransport == Transport.IBB) {
+ this.responder = this.id.with;
+ this.transportId = JingleConnectionManager.nextRandomId();
+ this.setupDescription(remoteVersion);
+ if (this.initialTransport == IbbTransportInfo.class) {
this.sendInitRequest();
} else {
gatherAndConnectDirectCandidates();
- this.mJingleConnectionManager.getPrimaryCandidate(account, initiating(), (success, candidate) -> {
+ this.jingleConnectionManager.getPrimaryCandidate(id.account, isInitiator(), (success, candidate) -> {
if (success) {
final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate);
connections.put(candidate.getCid(), socksConnection);
@@ -367,10 +386,10 @@ public class JingleConnection implements Transferable {
private void gatherAndConnectDirectCandidates() {
final List directCandidates;
if (Config.USE_DIRECT_JINGLE_CANDIDATES) {
- if (account.isOnion() || mXmppConnectionService.useTorToConnect()) {
+ if (id.account.isOnion() || xmppConnectionService.useTorToConnect()) {
directCandidates = Collections.emptyList();
} else {
- directCandidates = DirectConnectionUtils.getLocalCandidates(account.getJid());
+ directCandidates = DirectConnectionUtils.getLocalCandidates(id.account.getJid());
}
} else {
directCandidates = Collections.emptyList();
@@ -382,19 +401,21 @@ public class JingleConnection implements Transferable {
}
}
- private void upgradeNamespace(List remoteFeatures) {
- if (remoteFeatures.contains(Content.Version.FT_5.getNamespace())) {
- this.ftVersion = Content.Version.FT_5;
- } else if (remoteFeatures.contains(Content.Version.FT_4.getNamespace())) {
- this.ftVersion = Content.Version.FT_4;
+ private FileTransferDescription.Version getAvailableFileTransferVersion(List remoteFeatures) {
+ if (remoteFeatures.contains(FileTransferDescription.Version.FT_5.getNamespace())) {
+ return FileTransferDescription.Version.FT_5;
+ } else if (remoteFeatures.contains(FileTransferDescription.Version.FT_4.getNamespace())) {
+ return FileTransferDescription.Version.FT_4;
+ } else {
+ return FileTransferDescription.Version.FT_3;
}
}
private List getRemoteFeatures() {
- Jid jid = this.message.getCounterpart();
+ final Jid jid = this.id.with;
String resource = jid != null ? jid.getResource() : null;
if (resource != null) {
- Presence presence = this.account.getRoster().getContact(jid).getPresences().getPresences().get(resource);
+ Presence presence = this.id.account.getRoster().getContact(jid).getPresences().getPresences().get(resource);
ServiceDiscoveryResult result = presence != null ? presence.getServiceDiscoveryResult() : null;
return result == null ? Collections.emptyList() : result.getFeatures();
} else {
@@ -402,56 +423,47 @@ public class JingleConnection implements Transferable {
}
}
- public void init(Account account, JinglePacket packet) {
+ private void init(JinglePacket packet) { //should move to deliverPacket
+ //TODO if not 'OFFERED' reply with out-of-order
this.mJingleStatus = JINGLE_STATUS_INITIATED;
- Conversation conversation = this.mXmppConnectionService
- .findOrCreateConversation(account,
- packet.getFrom().asBareJid(), false, false);
+ final Conversation conversation = this.xmppConnectionService.findOrCreateConversation(id.account, id.with.asBareJid(), false, false);
this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
this.message.setStatus(Message.STATUS_RECEIVED);
this.mStatus = Transferable.STATUS_OFFER;
this.message.setTransferable(this);
- final Jid from = packet.getFrom();
- this.message.setCounterpart(from);
- this.account = account;
- this.initiator = packet.getFrom();
- this.responder = this.account.getJid();
- this.sessionId = packet.getSessionId();
- Content content = packet.getJingleContent();
- this.contentCreator = content.getAttribute("creator");
- this.initialTransport = content.hasSocks5Transport() ? Transport.SOCKS : Transport.IBB;
+ this.message.setCounterpart(this.id.with);
+ this.responder = this.id.account.getJid();
+ final Content content = packet.getJingleContent();
+ final GenericTransportInfo transportInfo = content.getTransport();
+ this.contentCreator = content.getCreator();
this.contentName = content.getAttribute("name");
- this.transportId = content.getTransportId();
-
- if (this.initialTransport == Transport.SOCKS) {
- this.mergeCandidates(JingleCandidate.parse(content.socks5transport().getChildren()));
- } else if (this.initialTransport == Transport.IBB) {
- final String receivedBlockSize = content.ibbTransport().getAttribute("block-size");
- if (receivedBlockSize != null) {
- try {
- this.ibbBlockSize = Math.min(Integer.parseInt(receivedBlockSize), this.ibbBlockSize);
- } catch (NumberFormatException e) {
- Log.d(Config.LOGTAG, "number format exception " + e.getMessage());
- respondToIq(packet, false);
- this.fail();
- return;
- }
- } else {
- Log.d(Config.LOGTAG, "received block size was null");
+ if (transportInfo instanceof S5BTransportInfo) {
+ final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo;
+ this.transportId = s5BTransportInfo.getTransportId();
+ this.initialTransport = s5BTransportInfo.getClass();
+ this.mergeCandidates(s5BTransportInfo.getCandidates());
+ } else if (transportInfo instanceof IbbTransportInfo) {
+ final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo;
+ this.initialTransport = ibbTransportInfo.getClass();
+ this.transportId = ibbTransportInfo.getTransportId();
+ final int remoteBlockSize = ibbTransportInfo.getBlockSize();
+ if (remoteBlockSize <= 0) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote party requested invalid ibb block size");
respondToIq(packet, false);
this.fail();
- return;
}
- }
- this.ftVersion = content.getVersion();
- if (ftVersion == null) {
+ this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, ibbTransportInfo.getBlockSize());
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote tried to use unknown transport " + transportInfo.getNamespace());
respondToIq(packet, false);
this.fail();
return;
}
- this.fileOffer = content.getFileOffer(this.ftVersion);
+ this.description = (FileTransferDescription) content.getDescription();
+
+ final Element fileOffer = this.description.getFileOffer();
if (fileOffer != null) {
boolean remoteIsUsingJet = false;
@@ -459,7 +471,7 @@ public class JingleConnection implements Transferable {
if (encrypted == null) {
final Element security = content.findChild("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT);
if (security != null && AxolotlService.PEP_PREFIX.equals(security.getAttribute("type"))) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received jingle file offer with JET");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received jingle file offer with JET");
encrypted = security.findChild("encrypted", AxolotlService.PEP_PREFIX);
remoteIsUsingJet = true;
}
@@ -490,10 +502,10 @@ public class JingleConnection implements Transferable {
long size = parseLong(fileSize, 0);
message.setBody(Long.toString(size));
conversation.add(message);
- mJingleConnectionManager.updateConversationUi(true);
- this.file = this.mXmppConnectionService.getFileBackend().getFile(message, false);
+ jingleConnectionManager.updateConversationUi(true);
+ this.file = this.xmppConnectionService.getFileBackend().getFile(message, false);
if (mXmppAxolotlMessage != null) {
- XmppAxolotlMessage.XmppAxolotlKeyTransportMessage transportMessage = account.getAxolotlService().processReceivingKeyTransportMessage(mXmppAxolotlMessage, false);
+ XmppAxolotlMessage.XmppAxolotlKeyTransportMessage transportMessage = id.account.getAxolotlService().processReceivingKeyTransportMessage(mXmppAxolotlMessage, false);
if (transportMessage != null) {
message.setEncryption(Message.ENCRYPTION_AXOLOTL);
this.file.setKey(transportMessage.getKey());
@@ -511,11 +523,11 @@ public class JingleConnection implements Transferable {
respondToIq(packet, true);
- if (account.getRoster().getContact(from).showInContactList()
- && mJingleConnectionManager.hasStoragePermission()
- && size < this.mJingleConnectionManager.getAutoAcceptFileSize()
- && mXmppConnectionService.isDataSaverDisabled()) {
- Log.d(Config.LOGTAG, "auto accepting file from " + from);
+ if (id.account.getRoster().getContact(id.with).showInContactList()
+ && jingleConnectionManager.hasStoragePermission()
+ && size < this.jingleConnectionManager.getAutoAcceptFileSize()
+ && xmppConnectionService.isDataSaverDisabled()) {
+ Log.d(Config.LOGTAG, "auto accepting file from " + id.with);
this.acceptedAutomatically = true;
this.sendAccept();
} else {
@@ -524,9 +536,9 @@ public class JingleConnection implements Transferable {
"not auto accepting new file offer with size: "
+ size
+ " allowed size:"
- + this.mJingleConnectionManager
+ + this.jingleConnectionManager
.getAutoAcceptFileSize());
- this.mXmppConnectionService.getNotificationService().push(message);
+ this.xmppConnectionService.getNotificationService().push(message);
}
Log.d(Config.LOGTAG, "receiving file: expecting size of " + this.file.getExpectedSize());
return;
@@ -535,101 +547,93 @@ public class JingleConnection implements Transferable {
}
}
- private static long parseLong(final Element element, final long l) {
- final String input = element == null ? null : element.getContent();
- if (input == null) {
- return l;
- }
- try {
- return Long.parseLong(input);
- } catch (Exception e) {
- return l;
+ private void setupDescription(final FileTransferDescription.Version version) {
+ this.file = this.xmppConnectionService.getFileBackend().getFile(message, false);
+ final FileTransferDescription description;
+ if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
+ this.file.setKey(mXmppAxolotlMessage.getInnerKey());
+ this.file.setIv(mXmppAxolotlMessage.getIV());
+ //legacy OMEMO encrypted file transfer reported file size of the encrypted file
+ //JET uses the file size of the plain text file. The difference is only 16 bytes (auth tag)
+ this.file.setExpectedSize(file.getSize() + (this.remoteSupportsOmemoJet ? 0 : 16));
+ if (remoteSupportsOmemoJet) {
+ description = FileTransferDescription.of(this.file, version, null);
+ } else {
+ description = FileTransferDescription.of(this.file, version, this.mXmppAxolotlMessage);
+ }
+ } else {
+ this.file.setExpectedSize(file.getSize());
+ description = FileTransferDescription.of(this.file, version, null);
}
+ this.description = description;
}
private void sendInitRequest() {
- JinglePacket packet = this.bootstrapPacket("session-initiate");
- Content content = new Content(this.contentCreator, this.contentName);
- if (message.isFileOrImage()) {
- content.setTransportId(this.transportId);
- this.file = this.mXmppConnectionService.getFileBackend().getFile(message, false);
- if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
- this.file.setKey(mXmppAxolotlMessage.getInnerKey());
- this.file.setIv(mXmppAxolotlMessage.getIV());
- //legacy OMEMO encrypted file transfer reported file size of the encrypted file
- //JET uses the file size of the plain text file. The difference is only 16 bytes (auth tag)
- this.file.setExpectedSize(file.getSize() + (this.remoteSupportsOmemoJet ? 0 : 16));
- final Element file = content.setFileOffer(this.file, false, this.ftVersion);
- if (remoteSupportsOmemoJet) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": remote announced support for JET");
- final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT);
- security.setAttribute("name", this.contentName);
- security.setAttribute("cipher", JET_OMEMO_CIPHER);
- security.setAttribute("type", AxolotlService.PEP_PREFIX);
- security.addChild(mXmppAxolotlMessage.toElement());
- content.addChild(security);
- } else {
- file.addChild(mXmppAxolotlMessage.toElement());
- }
- } else {
- this.file.setExpectedSize(file.getSize());
- content.setFileOffer(this.file, false, this.ftVersion);
- }
- message.resetFileParams();
- try {
- this.mFileInputStream = new FileInputStream(file);
- } catch (FileNotFoundException e) {
- fail(e.getMessage());
- return;
- }
- content.setTransportId(this.transportId);
- if (this.initialTransport == Transport.IBB) {
- content.ibbTransport().setAttribute("block-size", Integer.toString(this.ibbBlockSize));
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending IBB offer");
- } else {
- final List candidates = getCandidatesAsElements();
- Log.d(Config.LOGTAG, String.format("%s: sending S5B offer with %d candidates", account.getJid().asBareJid(), candidates.size()));
- content.socks5transport().setChildren(candidates);
- }
- packet.setContent(content);
- this.sendJinglePacket(packet, (account, response) -> {
- if (response.getType() == IqPacket.TYPE.RESULT) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": other party received offer");
- if (mJingleStatus == JINGLE_STATUS_OFFERED) {
- mJingleStatus = JINGLE_STATUS_INITIATED;
- mXmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
- } else {
- Log.d(Config.LOGTAG, "received ack for offer when status was " + mJingleStatus);
- }
- } else {
- fail(IqParser.extractErrorMessage(response));
- }
- });
-
+ final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INITIATE);
+ final Content content = new Content(this.contentCreator, this.contentName);
+ if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && remoteSupportsOmemoJet) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote announced support for JET");
+ final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT);
+ security.setAttribute("name", this.contentName);
+ security.setAttribute("cipher", JET_OMEMO_CIPHER);
+ security.setAttribute("type", AxolotlService.PEP_PREFIX);
+ security.addChild(mXmppAxolotlMessage.toElement());
+ content.addChild(security);
}
+ content.setDescription(this.description);
+ message.resetFileParams();
+ try {
+ this.mFileInputStream = new FileInputStream(file);
+ } catch (FileNotFoundException e) {
+ fail(e.getMessage());
+ return;
+ }
+ if (this.initialTransport == IbbTransportInfo.class) {
+ content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending IBB offer");
+ } else {
+ final Collection candidates = getOurCandidates();
+ content.setTransport(new S5BTransportInfo(this.transportId, candidates));
+ Log.d(Config.LOGTAG, String.format("%s: sending S5B offer with %d candidates", id.account.getJid().asBareJid(), candidates.size()));
+ }
+ packet.addJingleContent(content);
+ this.sendJinglePacket(packet, (account, response) -> {
+ if (response.getType() == IqPacket.TYPE.RESULT) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party received offer");
+ if (mJingleStatus == JINGLE_STATUS_OFFERED) {
+ mJingleStatus = JINGLE_STATUS_INITIATED;
+ xmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
+ } else {
+ Log.d(Config.LOGTAG, "received ack for offer when status was " + mJingleStatus);
+ }
+ } else {
+ fail(IqParser.extractErrorMessage(response));
+ }
+ });
+
}
private void sendHash() {
- JinglePacket packet = this.bootstrapPacket("session-info");
- packet.addChecksum(file.getSha1Sum(), ftVersion.getNamespace());
+ final Element checksum = new Element("checksum", description.getVersion().getNamespace());
+ 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(file.getSha1Sum(), Base64.NO_WRAP));
+
+ final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INFO);
+ packet.addJingleChild(checksum);
this.sendJinglePacket(packet);
}
- private List getCandidatesAsElements() {
- List elements = new ArrayList<>();
- for (JingleCandidate c : this.candidates) {
- if (c.isOurs()) {
- elements.add(c.toElement());
- }
- }
- return elements;
+ public Collection getOurCandidates() {
+ return Collections2.filter(this.candidates, c -> c != null && c.isOurs());
}
private void sendAccept() {
mJingleStatus = JINGLE_STATUS_ACCEPTED;
this.mStatus = Transferable.STATUS_DOWNLOADING;
- this.mJingleConnectionManager.updateConversationUi(true);
- if (initialTransport == Transport.SOCKS) {
+ this.jingleConnectionManager.updateConversationUi(true);
+ if (initialTransport == S5BTransportInfo.class) {
sendAcceptSocks();
} else {
sendAcceptIbb();
@@ -638,11 +642,10 @@ public class JingleConnection implements Transferable {
private void sendAcceptSocks() {
gatherAndConnectDirectCandidates();
- this.mJingleConnectionManager.getPrimaryCandidate(this.account, initiating(), (success, candidate) -> {
- final JinglePacket packet = bootstrapPacket("session-accept");
+ this.jingleConnectionManager.getPrimaryCandidate(this.id.account, isInitiator(), (success, candidate) -> {
+ final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT);
final Content content = new Content(contentCreator, contentName);
- content.setFileOffer(fileOffer, ftVersion);
- content.setTransportId(transportId);
+ content.setDescription(this.description);
if (success && candidate != null && !equalCandidateExists(candidate)) {
final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate);
connections.put(candidate.getCid(), socksConnection);
@@ -651,8 +654,8 @@ public class JingleConnection implements Transferable {
@Override
public void failed() {
Log.d(Config.LOGTAG, "connection to our own proxy65 candidate failed");
- content.socks5transport().setChildren(getCandidatesAsElements());
- packet.setContent(content);
+ content.setTransport(new S5BTransportInfo(transportId, getOurCandidates()));
+ packet.addJingleContent(content);
sendJinglePacket(packet);
connectNextCandidate();
}
@@ -661,16 +664,16 @@ public class JingleConnection implements Transferable {
public void established() {
Log.d(Config.LOGTAG, "connected to proxy65 candidate");
mergeCandidate(candidate);
- content.socks5transport().setChildren(getCandidatesAsElements());
- packet.setContent(content);
+ content.setTransport(new S5BTransportInfo(transportId, getOurCandidates()));
+ packet.addJingleContent(content);
sendJinglePacket(packet);
connectNextCandidate();
}
});
} else {
Log.d(Config.LOGTAG, "did not find a proxy65 candidate for ourselves");
- content.socks5transport().setChildren(getCandidatesAsElements());
- packet.setContent(content);
+ content.setTransport(new S5BTransportInfo(transportId, getOurCandidates()));
+ packet.addJingleContent(content);
sendJinglePacket(packet);
connectNextCandidate();
}
@@ -679,63 +682,57 @@ public class JingleConnection implements Transferable {
private void sendAcceptIbb() {
this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
- final JinglePacket packet = bootstrapPacket("session-accept");
+ final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT);
final Content content = new Content(contentCreator, contentName);
- content.setFileOffer(fileOffer, ftVersion);
- content.setTransportId(transportId);
- content.ibbTransport().setAttribute("block-size", this.ibbBlockSize);
- packet.setContent(content);
+ content.setDescription(this.description);
+ content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
+ packet.addJingleContent(content);
this.transport.receive(file, onFileTransmissionStatusChanged);
this.sendJinglePacket(packet);
}
- private JinglePacket bootstrapPacket(String action) {
- JinglePacket packet = new JinglePacket();
- packet.setAction(action);
- packet.setFrom(account.getJid());
- packet.setTo(this.message.getCounterpart());
- packet.setSessionId(this.sessionId);
- packet.setInitiator(this.initiator);
+ private JinglePacket bootstrapPacket(JinglePacket.Action action) {
+ final JinglePacket packet = new JinglePacket(action, this.id.sessionId);
+ packet.setTo(id.with);
return packet;
}
private void sendJinglePacket(JinglePacket packet) {
- mXmppConnectionService.sendIqPacket(account, packet, responseListener);
+ xmppConnectionService.sendIqPacket(id.account, packet, responseListener);
}
private void sendJinglePacket(JinglePacket packet, OnIqPacketReceived callback) {
- mXmppConnectionService.sendIqPacket(account, packet, callback);
+ xmppConnectionService.sendIqPacket(id.account, packet, callback);
}
private void receiveAccept(JinglePacket packet) {
if (responding()) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received out of order session-accept (we were responding)");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept (we were responding)");
respondToIqWithOutOfOrder(packet);
return;
}
if (this.mJingleStatus != JINGLE_STATUS_INITIATED) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received out of order session-accept");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept");
respondToIqWithOutOfOrder(packet);
return;
}
this.mJingleStatus = JINGLE_STATUS_ACCEPTED;
- mXmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
- Content content = packet.getJingleContent();
- if (content.hasSocks5Transport()) {
+ xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
+ final Content content = packet.getJingleContent();
+ final GenericTransportInfo transportInfo = content.getTransport();
+ //TODO we want to fail if transportInfo doesn’t match our intialTransport and/or our id
+ if (transportInfo instanceof S5BTransportInfo) {
+ final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo;
respondToIq(packet, true);
- mergeCandidates(JingleCandidate.parse(content.socks5transport().getChildren()));
+ //TODO calling merge is probably a bug because that might eliminate candidates of the other party and lead to us not sending accept/deny
+ //TODO: we probably just want to call add
+ mergeCandidates(s5BTransportInfo.getCandidates());
this.connectNextCandidate();
- } else if (content.hasIbbTransport()) {
- String receivedBlockSize = packet.getJingleContent().ibbTransport().getAttribute("block-size");
- if (receivedBlockSize != null) {
- try {
- int bs = Integer.parseInt(receivedBlockSize);
- if (bs > this.ibbBlockSize) {
- this.ibbBlockSize = bs;
- }
- } catch (Exception e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to parse block size in session-accept");
- }
+ } else if (transportInfo instanceof IbbTransportInfo) {
+ final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo;
+ final int remoteBlockSize = ibbTransportInfo.getBlockSize();
+ if (remoteBlockSize > 0) {
+ this.ibbBlockSize = Math.min(ibbBlockSize, remoteBlockSize);
}
respondToIq(packet, true);
this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
@@ -747,13 +744,15 @@ public class JingleConnection implements Transferable {
private void receiveTransportInfo(JinglePacket packet) {
final Content content = packet.getJingleContent();
- if (content.hasSocks5Transport()) {
- if (content.socks5transport().hasChild("activated")) {
+ final GenericTransportInfo transportInfo = content.getTransport();
+ if (transportInfo instanceof S5BTransportInfo) {
+ final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo;
+ if (s5BTransportInfo.hasChild("activated")) {
respondToIq(packet, true);
if ((this.transport != null) && (this.transport instanceof JingleSocks5Transport)) {
onProxyActivated.success();
} else {
- String cid = content.socks5transport().findChild("activated").getAttribute("cid");
+ String cid = s5BTransportInfo.findChild("activated").getAttribute("cid");
Log.d(Config.LOGTAG, "received proxy activated (" + cid
+ ")prior to choosing our own transport");
JingleSocks5Transport connection = this.connections.get(cid);
@@ -761,22 +760,22 @@ public class JingleConnection implements Transferable {
connection.setActivated(true);
} else {
Log.d(Config.LOGTAG, "activated connection not found");
- sendSessionTerminate("failed-transport");
+ sendSessionTerminate(Reason.FAILED_TRANSPORT);
this.fail();
}
}
- } else if (content.socks5transport().hasChild("proxy-error")) {
+ } else if (s5BTransportInfo.hasChild("proxy-error")) {
respondToIq(packet, true);
onProxyActivated.failed();
- } else if (content.socks5transport().hasChild("candidate-error")) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received candidate error");
+ } else if (s5BTransportInfo.hasChild("candidate-error")) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received candidate error");
respondToIq(packet, true);
this.receivedCandidate = true;
if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) {
this.connect();
}
- } else if (content.socks5transport().hasChild("candidate-used")) {
- String cid = content.socks5transport().findChild("candidate-used").getAttribute("cid");
+ } else if (s5BTransportInfo.hasChild("candidate-used")) {
+ String cid = s5BTransportInfo.findChild("candidate-used").getAttribute("cid");
if (cid != null) {
Log.d(Config.LOGTAG, "candidate used by counterpart:" + cid);
JingleCandidate candidate = getCandidate(cid);
@@ -808,21 +807,21 @@ public class JingleConnection implements Transferable {
final JingleSocks5Transport connection = chooseConnection();
this.transport = connection;
if (connection == null) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not find suitable candidate");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": could not find suitable candidate");
this.disconnectSocks5Connections();
- if (initiating()) {
+ if (isInitiator()) {
this.sendFallbackToIbb();
}
} else {
final JingleCandidate candidate = connection.getCandidate();
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": elected candidate " + candidate.getHost() + ":" + candidate.getPort());
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": elected candidate " + candidate.getHost() + ":" + candidate.getPort());
this.mJingleStatus = JINGLE_STATUS_TRANSMITTING;
if (connection.needsActivation()) {
if (connection.getCandidate().isOurs()) {
final String sid;
- if (ftVersion == Content.Version.FT_3) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": use session ID instead of transport ID to activate proxy");
- sid = getSessionId();
+ if (description.getVersion() == FileTransferDescription.Version.FT_3) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": use session ID instead of transport ID to activate proxy");
+ sid = id.sessionId;
} else {
sid = getTransportId();
}
@@ -834,10 +833,10 @@ public class JingleConnection implements Transferable {
activation.query("http://jabber.org/protocol/bytestreams")
.setAttribute("sid", sid);
activation.query().addChild("activate")
- .setContent(this.getCounterPart().toString());
- mXmppConnectionService.sendIqPacket(account, activation, (account, response) -> {
+ .setContent(this.id.with.toEscapedString());
+ xmppConnectionService.sendIqPacket(this.id.account, activation, (account, response) -> {
if (response.getType() != IqPacket.TYPE.RESULT) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + response.toString());
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": " + response.toString());
sendProxyError();
onProxyActivated.failed();
} else {
@@ -852,7 +851,7 @@ public class JingleConnection implements Transferable {
+ " was a proxy. waiting for other party to activate");
}
} else {
- if (initiating()) {
+ if (isInitiator()) {
Log.d(Config.LOGTAG, "we were initiating. sending file");
connection.send(file, onFileTransmissionStatusChanged);
} else {
@@ -882,7 +881,7 @@ public class JingleConnection implements Transferable {
} else if (connection.getCandidate().getPriority() == currentConnection
.getCandidate().getPriority()) {
// Log.d(Config.LOGTAG,"found two candidates with same priority");
- if (initiating()) {
+ if (isInitiator()) {
if (currentConnection.getCandidate().isOurs()) {
connection = currentConnection;
}
@@ -899,69 +898,61 @@ public class JingleConnection implements Transferable {
}
private void sendSuccess() {
- sendSessionTerminate("success");
+ sendSessionTerminate(Reason.SUCCESS);
this.disconnectSocks5Connections();
this.mJingleStatus = JINGLE_STATUS_FINISHED;
this.message.setStatus(Message.STATUS_RECEIVED);
this.message.setTransferable(null);
- this.mXmppConnectionService.updateMessage(message, false);
- this.mJingleConnectionManager.finishConnection(this);
+ this.xmppConnectionService.updateMessage(message, false);
+ this.jingleConnectionManager.finishConnection(this);
}
private void sendFallbackToIbb() {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending fallback to ibb");
- JinglePacket packet = this.bootstrapPacket("transport-replace");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending fallback to ibb");
+ JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.TRANSPORT_REPLACE);
Content content = new Content(this.contentCreator, this.contentName);
- this.transportId = this.mJingleConnectionManager.nextRandomId();
- content.setTransportId(this.transportId);
- content.ibbTransport().setAttribute("block-size",
- Integer.toString(this.ibbBlockSize));
- packet.setContent(content);
+ this.transportId = JingleConnectionManager.nextRandomId();
+ content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
+ packet.addJingleContent(content);
this.sendJinglePacket(packet);
}
- private void receiveFallbackToIbb(JinglePacket packet) {
- if (initiating()) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received out of order transport-replace (we were initiating)");
+ private void receiveFallbackToIbb(final JinglePacket packet, final IbbTransportInfo transportInfo) {
+ if (isInitiator()) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace (we were initiating)");
respondToIqWithOutOfOrder(packet);
return;
}
final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING);
if (!validState) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received out of order transport-replace");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace");
respondToIqWithOutOfOrder(packet);
return;
}
this.proxyActivationFailed = false; //fallback received; now we no longer need to accept another one;
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": receiving fallback to ibb");
- final String receivedBlockSize = packet.getJingleContent().ibbTransport().getAttribute("block-size");
- if (receivedBlockSize != null) {
- try {
- final int bs = Integer.parseInt(receivedBlockSize);
- if (bs < this.ibbBlockSize) {
- this.ibbBlockSize = bs;
- }
- } catch (NumberFormatException e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to parse block size in transport-replace");
- }
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receiving fallback to ibb");
+ final int remoteBlockSize = transportInfo.getBlockSize();
+ if (remoteBlockSize > 0) {
+ this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize);
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to parse block size in transport-replace");
}
- this.transportId = packet.getJingleContent().getTransportId();
+ this.transportId = transportInfo.getTransportId(); //TODO: handle the case where this is null by the remote party
this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
- final JinglePacket answer = bootstrapPacket("transport-accept");
+ final JinglePacket answer = bootstrapPacket(JinglePacket.Action.TRANSPORT_ACCEPT);
final Content content = new Content(contentCreator, contentName);
- content.ibbTransport().setAttribute("block-size", this.ibbBlockSize);
- content.ibbTransport().setAttribute("sid", this.transportId);
- answer.setContent(content);
+ content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
+ answer.addJingleContent(content);
respondToIq(packet, true);
- if (initiating()) {
+ if (isInitiator()) {
this.sendJinglePacket(answer, (account, response) -> {
if (response.getType() == IqPacket.TYPE.RESULT) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + " recipient ACKed our transport-accept. creating ibb");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + " recipient ACKed our transport-accept. creating ibb");
transport.connect(onIbbTransportConnected);
}
});
@@ -973,83 +964,78 @@ public class JingleConnection implements Transferable {
private void receiveTransportAccept(JinglePacket packet) {
if (responding()) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received out of order transport-accept (we were responding)");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept (we were responding)");
respondToIqWithOutOfOrder(packet);
return;
}
final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING);
if (!validState) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received out of order transport-accept");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept");
respondToIqWithOutOfOrder(packet);
return;
}
this.proxyActivationFailed = false; //fallback accepted; now we no longer need to accept another one;
- if (packet.getJingleContent().hasIbbTransport()) {
- final Element ibbTransport = packet.getJingleContent().ibbTransport();
- final String receivedBlockSize = ibbTransport.getAttribute("block-size");
- final String sid = ibbTransport.getAttribute("sid");
- if (receivedBlockSize != null) {
- try {
- int bs = Integer.parseInt(receivedBlockSize);
- if (bs < this.ibbBlockSize) {
- this.ibbBlockSize = bs;
- }
- } catch (NumberFormatException e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to parse block size in transport-accept");
- }
+ final Content content = packet.getJingleContent();
+ final GenericTransportInfo transportInfo = content == null ? null : content.getTransport();
+ if (transportInfo instanceof IbbTransportInfo) {
+ final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo;
+ final int remoteBlockSize = ibbTransportInfo.getBlockSize();
+ if (remoteBlockSize > 0) {
+ this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize);
}
+ final String sid = ibbTransportInfo.getTransportId();
this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
if (sid == null || !sid.equals(this.transportId)) {
- Log.w(Config.LOGTAG, String.format("%s: sid in transport-accept (%s) did not match our sid (%s) ", account.getJid().asBareJid(), sid, transportId));
+ Log.w(Config.LOGTAG, String.format("%s: sid in transport-accept (%s) did not match our sid (%s) ", id.account.getJid().asBareJid(), sid, transportId));
}
respondToIq(packet, true);
//might be receive instead if we are not initiating
- if (initiating()) {
+ if (isInitiator()) {
this.transport.connect(onIbbTransportConnected);
}
} else {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received invalid transport-accept");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received invalid transport-accept");
respondToIq(packet, false);
}
}
private void receiveSuccess() {
- if (initiating()) {
+ if (isInitiator()) {
this.mJingleStatus = JINGLE_STATUS_FINISHED;
- this.mXmppConnectionService.markMessage(this.message, Message.STATUS_SEND_RECEIVED);
+ this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_RECEIVED);
this.disconnectSocks5Connections();
if (this.transport instanceof JingleInBandTransport) {
this.transport.disconnect();
}
this.message.setTransferable(null);
- this.mJingleConnectionManager.finishConnection(this);
+ this.jingleConnectionManager.finishConnection(this);
} else {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received session-terminate/success while responding");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate/success while responding");
}
}
@Override
public void cancel() {
this.cancelled = true;
- abort("cancel");
+ abort(Reason.CANCEL);
}
- void abort(final String reason) {
+ void abort(final Reason reason) {
this.disconnectSocks5Connections();
if (this.transport instanceof JingleInBandTransport) {
this.transport.disconnect();
}
sendSessionTerminate(reason);
- this.mJingleConnectionManager.finishConnection(this);
+ this.jingleConnectionManager.finishConnection(this);
if (responding()) {
this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED));
if (this.file != null) {
file.delete();
}
- this.mJingleConnectionManager.updateConversationUi(true);
+ this.jingleConnectionManager.updateConversationUi(true);
} else {
- this.mXmppConnectionService.markMessage(this.message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : null);
+ this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : null);
this.message.setTransferable(null);
}
}
@@ -1072,22 +1058,20 @@ public class JingleConnection implements Transferable {
if (this.file != null) {
file.delete();
}
- this.mJingleConnectionManager.updateConversationUi(true);
+ this.jingleConnectionManager.updateConversationUi(true);
} else {
- this.mXmppConnectionService.markMessage(this.message,
+ this.xmppConnectionService.markMessage(this.message,
Message.STATUS_SEND_FAILED,
cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage);
this.message.setTransferable(null);
}
}
- this.mJingleConnectionManager.finishConnection(this);
+ this.jingleConnectionManager.finishConnection(this);
}
- private void sendSessionTerminate(String reason) {
- final JinglePacket packet = bootstrapPacket("session-terminate");
- final Reason r = new Reason();
- r.addChild(reason);
- packet.setReason(r);
+ private void sendSessionTerminate(Reason reason) {
+ final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_TERMINATE);
+ packet.setReason(reason, null);
this.sendJinglePacket(packet);
}
@@ -1137,29 +1121,26 @@ public class JingleConnection implements Transferable {
}
private void sendProxyActivated(String cid) {
- final JinglePacket packet = bootstrapPacket("transport-info");
+ final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
final Content content = new Content(this.contentCreator, this.contentName);
- content.setTransportId(this.transportId);
- content.socks5transport().addChild("activated").setAttribute("cid", cid);
- packet.setContent(content);
+ content.setTransport(new S5BTransportInfo(this.transportId, new Element("activated").setAttribute("cid", cid)));
+ packet.addJingleContent(content);
this.sendJinglePacket(packet);
}
private void sendProxyError() {
- final JinglePacket packet = bootstrapPacket("transport-info");
+ final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
final Content content = new Content(this.contentCreator, this.contentName);
- content.setTransportId(this.transportId);
- content.socks5transport().addChild("proxy-error");
- packet.setContent(content);
+ content.setTransport(new S5BTransportInfo(this.transportId, new Element("proxy-error")));
+ packet.addJingleContent(content);
this.sendJinglePacket(packet);
}
private void sendCandidateUsed(final String cid) {
- JinglePacket packet = bootstrapPacket("transport-info");
- Content content = new Content(this.contentCreator, this.contentName);
- content.setTransportId(this.transportId);
- content.socks5transport().addChild("candidate-used").setAttribute("cid", cid);
- packet.setContent(content);
+ JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
+ final Content content = new Content(this.contentCreator, this.contentName);
+ content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-used").setAttribute("cid", cid)));
+ packet.addJingleContent(content);
this.sentCandidate = true;
if ((receivedCandidate) && (mJingleStatus == JINGLE_STATUS_ACCEPTED)) {
connect();
@@ -1168,12 +1149,11 @@ public class JingleConnection implements Transferable {
}
private void sendCandidateError() {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending candidate error");
- JinglePacket packet = bootstrapPacket("transport-info");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending candidate error");
+ JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
Content content = new Content(this.contentCreator, this.contentName);
- content.setTransportId(this.transportId);
- content.socks5transport().addChild("candidate-error");
- packet.setContent(content);
+ content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-error")));
+ packet.addJingleContent(content);
this.sentCandidate = true;
this.sendJinglePacket(packet);
if (receivedCandidate && mJingleStatus == JINGLE_STATUS_ACCEPTED) {
@@ -1221,19 +1201,15 @@ public class JingleConnection implements Transferable {
void updateProgress(int i) {
this.mProgress = i;
- mJingleConnectionManager.updateConversationUi(false);
+ jingleConnectionManager.updateConversationUi(false);
}
public String getTransportId() {
return this.transportId;
}
- public Content.Version getFtVersion() {
- return this.ftVersion;
- }
-
- public boolean hasTransportId(String sid) {
- return sid.equals(this.transportId);
+ public FileTransferDescription.Version getFtVersion() {
+ return this.description.getVersion();
}
public JingleTransport getTransport() {
@@ -1241,7 +1217,7 @@ public class JingleConnection implements Transferable {
}
public boolean start() {
- if (account.getStatus() == Account.State.ONLINE) {
+ if (id.account.getStatus() == Account.State.ONLINE) {
if (mJingleStatus == JINGLE_STATUS_INITIATED) {
new Thread(this::sendAccept).start();
}
@@ -1270,8 +1246,8 @@ public class JingleConnection implements Transferable {
return this.mProgress;
}
- public AbstractConnectionManager getConnectionManager() {
- return this.mJingleConnectionManager;
+ AbstractConnectionManager getConnectionManager() {
+ return this.jingleConnectionManager;
}
interface OnProxyActivated {
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java
index 4182da08c..2dc0d9cab 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java
@@ -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();
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java
new file mode 100644
index 000000000..adc4c43e4
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java
@@ -0,0 +1,1159 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+
+import org.webrtc.EglBase;
+import org.webrtc.IceCandidate;
+import org.webrtc.PeerConnection;
+import org.webrtc.VideoTrack;
+
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+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.Conversation;
+import eu.siacs.conversations.entities.Conversational;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.entities.RtpSessionStatus;
+import eu.siacs.conversations.services.AppRTCAudioManager;
+import eu.siacs.conversations.xml.Element;
+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.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 JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
+
+ private static final long BUSY_TIME_OUT = 20;
+
+ public static final List STATES_SHOWING_ONGOING_CALL = Arrays.asList(
+ State.PROCEED,
+ State.SESSION_INITIALIZED,
+ State.SESSION_INITIALIZED_PRE_APPROVED,
+ State.SESSION_ACCEPTED
+ );
+
+ private static final List TERMINATED = Arrays.asList(
+ State.TERMINATED_SUCCESS,
+ State.TERMINATED_DECLINED_OR_BUSY,
+ State.TERMINATED_CONNECTIVITY_ERROR,
+ State.TERMINATED_CANCEL_OR_TIMEOUT,
+ State.TERMINATED_APPLICATION_FAILURE
+ );
+
+ private static final Map> VALID_TRANSITIONS;
+
+ static {
+ final ImmutableMap.Builder> transitionBuilder = new ImmutableMap.Builder<>();
+ transitionBuilder.put(State.NULL, ImmutableList.of(
+ State.PROPOSED,
+ State.SESSION_INITIALIZED,
+ State.TERMINATED_APPLICATION_FAILURE
+ ));
+ transitionBuilder.put(State.PROPOSED, ImmutableList.of(
+ State.ACCEPTED,
+ State.PROCEED,
+ State.REJECTED,
+ State.RETRACTED,
+ State.TERMINATED_APPLICATION_FAILURE,
+ State.TERMINATED_CONNECTIVITY_ERROR //only used when the xmpp connection rebinds
+ ));
+ transitionBuilder.put(State.PROCEED, ImmutableList.of(
+ State.SESSION_INITIALIZED_PRE_APPROVED,
+ State.TERMINATED_SUCCESS,
+ State.TERMINATED_APPLICATION_FAILURE,
+ State.TERMINATED_CONNECTIVITY_ERROR //at this state used for error bounces of the proceed message
+ ));
+ transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(
+ State.SESSION_ACCEPTED,
+ State.TERMINATED_SUCCESS,
+ State.TERMINATED_DECLINED_OR_BUSY,
+ State.TERMINATED_CONNECTIVITY_ERROR, //at this state used for IQ errors and IQ timeouts
+ State.TERMINATED_CANCEL_OR_TIMEOUT,
+ State.TERMINATED_APPLICATION_FAILURE
+ ));
+ transitionBuilder.put(State.SESSION_INITIALIZED_PRE_APPROVED, ImmutableList.of(
+ State.SESSION_ACCEPTED,
+ State.TERMINATED_SUCCESS,
+ State.TERMINATED_DECLINED_OR_BUSY,
+ State.TERMINATED_CONNECTIVITY_ERROR, //at this state used for IQ errors and IQ timeouts
+ State.TERMINATED_CANCEL_OR_TIMEOUT,
+ State.TERMINATED_APPLICATION_FAILURE
+ ));
+ transitionBuilder.put(State.SESSION_ACCEPTED, ImmutableList.of(
+ State.TERMINATED_SUCCESS,
+ State.TERMINATED_DECLINED_OR_BUSY,
+ State.TERMINATED_CONNECTIVITY_ERROR,
+ State.TERMINATED_CANCEL_OR_TIMEOUT,
+ State.TERMINATED_APPLICATION_FAILURE
+ ));
+ VALID_TRANSITIONS = transitionBuilder.build();
+ }
+
+ private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
+ private final ArrayDeque pendingIceCandidates = new ArrayDeque<>();
+ private final Message message;
+ private State state = State.NULL;
+ private Set proposedMedia;
+ private RtpContentMap initiatorRtpContentMap;
+ private RtpContentMap responderRtpContentMap;
+ private long rtpConnectionStarted = 0; //time of 'connected'
+ private ScheduledFuture> ringingTimeoutFuture;
+
+ JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
+ super(jingleConnectionManager, id, initiator);
+ final Conversation conversation = jingleConnectionManager.getXmppConnectionService().findOrCreateConversation(
+ id.account,
+ id.with.asBareJid(),
+ false,
+ false
+ );
+ this.message = new Message(
+ conversation,
+ isInitiator() ? Message.STATUS_SEND : Message.STATUS_RECEIVED,
+ Message.TYPE_RTP_SESSION,
+ id.sessionId
+ );
+ }
+
+ private static State reasonToState(Reason reason) {
+ switch (reason) {
+ case SUCCESS:
+ return State.TERMINATED_SUCCESS;
+ case DECLINE:
+ case BUSY:
+ return State.TERMINATED_DECLINED_OR_BUSY;
+ case CANCEL:
+ case TIMEOUT:
+ return State.TERMINATED_CANCEL_OR_TIMEOUT;
+ case FAILED_APPLICATION:
+ case SECURITY_ERROR:
+ case UNSUPPORTED_TRANSPORTS:
+ case UNSUPPORTED_APPLICATIONS:
+ return State.TERMINATED_APPLICATION_FAILURE;
+ default:
+ return State.TERMINATED_CONNECTIVITY_ERROR;
+ }
+ }
+
+ @Override
+ synchronized void deliverPacket(final JinglePacket jinglePacket) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
+ switch (jinglePacket.getAction()) {
+ case SESSION_INITIATE:
+ receiveSessionInitiate(jinglePacket);
+ break;
+ case TRANSPORT_INFO:
+ receiveTransportInfo(jinglePacket);
+ break;
+ case SESSION_ACCEPT:
+ receiveSessionAccept(jinglePacket);
+ break;
+ case SESSION_TERMINATE:
+ receiveSessionTerminate(jinglePacket);
+ break;
+ default:
+ respondOk(jinglePacket);
+ Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
+ break;
+ }
+ }
+
+ @Override
+ synchronized void notifyRebound() {
+ if (TERMINATED.contains(this.state)) {
+ return;
+ }
+ webRTCWrapper.close();
+ if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) {
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ }
+ if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
+ //we might have already changed resources (full jid) at this point; so this might not even reach the other party
+ sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
+ } else {
+ transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
+ finish();
+ }
+ }
+
+ private void receiveSessionTerminate(final JinglePacket jinglePacket) {
+ respondOk(jinglePacket);
+ final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
+ final State previous = this.state;
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session terminate reason=" + wrapper.reason + "(" + Strings.nullToEmpty(wrapper.text) + ") while in state " + previous);
+ if (TERMINATED.contains(previous)) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring session terminate because already in " + previous);
+ return;
+ }
+ webRTCWrapper.close();
+ final State target = reasonToState(wrapper.reason);
+ transitionOrThrow(target);
+ writeLogMessage(target);
+ if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ }
+ finish();
+ }
+
+ private void receiveTransportInfo(final JinglePacket jinglePacket) {
+ if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
+ respondOk(jinglePacket);
+ final RtpContentMap contentMap;
+ try {
+ contentMap = RtpContentMap.of(jinglePacket);
+ } catch (IllegalArgumentException | NullPointerException e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents; ignoring", e);
+ return;
+ }
+ final RtpContentMap rtpContentMap = isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
+ final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
+ final List identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
+ if (identificationTags.size() == 0) {
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
+ }
+ for (final Map.Entry content : contentMap.contents.entrySet()) {
+ final String ufrag = content.getValue().transport.getAttribute("ufrag");
+ for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
+ final String sdp;
+ try {
+ sdp = candidate.toSdpAttribute(ufrag);
+ } catch (IllegalArgumentException e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring invalid ICE candidate " + e.getMessage());
+ continue;
+ }
+ final String sdpMid = content.getKey();
+ final int mLineIndex = identificationTags.indexOf(sdpMid);
+ final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
+ if (isInState(State.SESSION_ACCEPTED)) {
+ Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
+ this.webRTCWrapper.addIceCandidate(iceCandidate);
+ } else {
+ this.pendingIceCandidates.offer(iceCandidate);
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": put ICE candidate on backlog");
+ }
+ }
+ }
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
+ terminateWithOutOfOrder(jinglePacket);
+ }
+ }
+
+ private void receiveSessionInitiate(final JinglePacket jinglePacket) {
+ if (isInitiator()) {
+ Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
+ terminateWithOutOfOrder(jinglePacket);
+ return;
+ }
+ final RtpContentMap contentMap;
+ try {
+ contentMap = RtpContentMap.of(jinglePacket);
+ contentMap.requireContentDescriptions();
+ contentMap.requireDTLSFingerprint();
+ } catch (final RuntimeException e) {
+ respondOk(jinglePacket);
+ sendSessionTerminate(Reason.of(e), e.getMessage());
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
+ return;
+ }
+ Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
+ final State target;
+ if (this.state == State.PROCEED) {
+ Preconditions.checkState(
+ proposedMedia != null && proposedMedia.size() > 0,
+ "proposed media must be set when processing pre-approved session-initiate"
+ );
+ if (!this.proposedMedia.equals(contentMap.getMedia())) {
+ sendSessionTerminate(Reason.SECURITY_ERROR, String.format(
+ "Your session proposal (Jingle Message Initiation) included media %s but your session-initiate was %s",
+ this.proposedMedia,
+ contentMap.getMedia()
+ ));
+ return;
+ }
+ target = State.SESSION_INITIALIZED_PRE_APPROVED;
+ } else {
+ target = State.SESSION_INITIALIZED;
+ }
+ if (transition(target, () -> this.initiatorRtpContentMap = contentMap)) {
+ respondOk(jinglePacket);
+ if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": automatically accepting session-initiate");
+ sendSessionAccept();
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received not pre-approved session-initiate. start ringing");
+ startRinging();
+ }
+ } else {
+ Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
+ terminateWithOutOfOrder(jinglePacket);
+ }
+ }
+
+ private void receiveSessionAccept(final JinglePacket jinglePacket) {
+ if (!isInitiator()) {
+ Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
+ terminateWithOutOfOrder(jinglePacket);
+ return;
+ }
+ final RtpContentMap contentMap;
+ try {
+ contentMap = RtpContentMap.of(jinglePacket);
+ contentMap.requireContentDescriptions();
+ contentMap.requireDTLSFingerprint();
+ } catch (final RuntimeException e) {
+ respondOk(jinglePacket);
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents in session-accept", e);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.of(e), e.getMessage());
+ return;
+ }
+ final Set initiatorMedia = this.initiatorRtpContentMap.getMedia();
+ if (!initiatorMedia.equals(contentMap.getMedia())) {
+ sendSessionTerminate(Reason.SECURITY_ERROR, String.format(
+ "Your session-included included media %s but our session-initiate was %s",
+ this.proposedMedia,
+ contentMap.getMedia()
+ ));
+ return;
+ }
+ Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
+ if (transition(State.SESSION_ACCEPTED)) {
+ respondOk(jinglePacket);
+ receiveSessionAccept(contentMap);
+ } else {
+ Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
+ respondOk(jinglePacket);
+ }
+ }
+
+ private void receiveSessionAccept(final RtpContentMap contentMap) {
+ this.responderRtpContentMap = contentMap;
+ final SessionDescription sessionDescription;
+ try {
+ sessionDescription = SessionDescription.of(contentMap);
+ } catch (final IllegalArgumentException | NullPointerException e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-accept to SDP", e);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
+ return;
+ }
+ org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
+ org.webrtc.SessionDescription.Type.ANSWER,
+ sessionDescription.toString()
+ );
+ try {
+ this.webRTCWrapper.setRemoteDescription(answer).get();
+ } catch (Exception e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to set remote description after receiving session-accept", e);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION);
+ }
+ }
+
+ private void sendSessionAccept() {
+ final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
+ if (rtpContentMap == null) {
+ throw new IllegalStateException("initiator RTP Content Map has not been set");
+ }
+ final SessionDescription offer;
+ try {
+ offer = SessionDescription.of(rtpContentMap);
+ } catch (final IllegalArgumentException | NullPointerException e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-initiate to SDP", e);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
+ return;
+ }
+ sendSessionAccept(rtpContentMap.getMedia(), offer);
+ }
+
+ private void sendSessionAccept(final Set media, final SessionDescription offer) {
+ discoverIceServers(iceServers -> sendSessionAccept(media, offer, iceServers));
+ }
+
+ private synchronized void sendSessionAccept(final Set media, final SessionDescription offer, final List iceServers) {
+ if (TERMINATED.contains(this.state)) {
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": ICE servers got discovered when session was already terminated. nothing to do.");
+ return;
+ }
+ try {
+ setupWebRTC(media, iceServers);
+ } catch (WebRTCWrapper.InitializationException e) {
+ sendSessionTerminate(Reason.FAILED_APPLICATION);
+ return;
+ }
+ final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription(
+ org.webrtc.SessionDescription.Type.OFFER,
+ offer.toString()
+ );
+ try {
+ this.webRTCWrapper.setRemoteDescription(sdp).get();
+ addIceCandidatesFromBlackLog();
+ org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
+ final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
+ final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
+ sendSessionAccept(respondingRtpContentMap);
+ this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
+ } catch (Exception e) {
+ Log.d(Config.LOGTAG, "unable to send session accept", e);
+
+ }
+ }
+
+ private void addIceCandidatesFromBlackLog() {
+ while (!this.pendingIceCandidates.isEmpty()) {
+ final IceCandidate iceCandidate = this.pendingIceCandidates.poll();
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": added ICE candidate from back log " + iceCandidate);
+ this.webRTCWrapper.addIceCandidate(iceCandidate);
+ }
+ }
+
+ private void sendSessionAccept(final RtpContentMap rtpContentMap) {
+ this.responderRtpContentMap = rtpContentMap;
+ this.transitionOrThrow(State.SESSION_ACCEPTED);
+ final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
+ Log.d(Config.LOGTAG, sessionAccept.toString());
+ send(sessionAccept);
+ }
+
+ synchronized void deliveryMessage(final Jid from, final Element message, final String serverMessageId, final long timestamp) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
+ switch (message.getName()) {
+ case "propose":
+ receivePropose(from, Propose.upgrade(message), serverMessageId, timestamp);
+ break;
+ case "proceed":
+ receiveProceed(from, serverMessageId, timestamp);
+ break;
+ case "retract":
+ receiveRetract(from, serverMessageId, timestamp);
+ break;
+ case "reject":
+ receiveReject(from, serverMessageId, timestamp);
+ break;
+ case "accept":
+ receiveAccept(from, serverMessageId, timestamp);
+ break;
+ default:
+ break;
+ }
+ }
+
+ void deliverFailedProceed() {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receive message error for proceed message");
+ if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
+ webRTCWrapper.close();
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into connectivity error");
+ this.finish();
+ }
+ }
+
+ private void receiveAccept(final Jid from, final String serverMsgId, final long timestamp) {
+ final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
+ if (originatedFromMyself) {
+ if (transition(State.ACCEPTED)) {
+ if (serverMsgId != null) {
+ this.message.setServerMsgId(serverMsgId);
+ }
+ this.message.setTime(timestamp);
+ this.message.setCarbon(true); //indicate that call was accepted on other device
+ this.writeLogMessageSuccess(0);
+ this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ this.finish();
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
+ }
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
+ }
+ }
+
+ private void receiveReject(Jid from, String serverMsgId, long timestamp) {
+ final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
+ //reject from another one of my clients
+ if (originatedFromMyself) {
+ if (transition(State.REJECTED)) {
+ this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ this.finish();
+ if (serverMsgId != null) {
+ this.message.setServerMsgId(serverMsgId);
+ }
+ this.message.setTime(timestamp);
+ this.message.setCarbon(true); //indicate that call was rejected on other device
+ writeLogMessageMissed();
+ } else {
+ Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
+ }
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
+ }
+ }
+
+ private void receivePropose(final Jid from, final Propose propose, final String serverMsgId, final long timestamp) {
+ final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
+ if (originatedFromMyself) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
+ } else if (transition(State.PROPOSED, () -> {
+ final Collection descriptions = Collections2.transform(
+ Collections2.filter(propose.getDescriptions(), d -> d instanceof RtpDescription),
+ input -> (RtpDescription) input
+ );
+ final Collection media = Collections2.transform(descriptions, RtpDescription::getMedia);
+ Preconditions.checkState(!media.contains(Media.UNKNOWN), "RTP descriptions contain unknown media");
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session proposal from " + from + " for " + media);
+ this.proposedMedia = Sets.newHashSet(media);
+ })) {
+ if (serverMsgId != null) {
+ this.message.setServerMsgId(serverMsgId);
+ }
+ this.message.setTime(timestamp);
+ startRinging();
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
+ }
+ }
+
+ private void startRinging() {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
+ ringingTimeoutFuture = jingleConnectionManager.schedule(this::ringingTimeout, BUSY_TIME_OUT, TimeUnit.SECONDS);
+ xmppConnectionService.getNotificationService().showIncomingCallNotification(id, getMedia());
+ }
+
+ private synchronized void ringingTimeout() {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": timeout reached for ringing");
+ switch (this.state) {
+ case PROPOSED:
+ rejectCallFromProposed();
+ break;
+ case SESSION_INITIALIZED:
+ rejectCallFromSessionInitiate();
+ break;
+ }
+ }
+
+ private void cancelRingingTimeout() {
+ final ScheduledFuture> future = this.ringingTimeoutFuture;
+ if (future != null && !future.isCancelled()) {
+ future.cancel(false);
+ }
+ }
+
+ private void receiveProceed(final Jid from, final String serverMsgId, final long timestamp) {
+ final Set media = Preconditions.checkNotNull(this.proposedMedia, "Proposed media has to be set before handling proceed");
+ Preconditions.checkState(media.size() > 0, "Proposed media should not be empty");
+ if (from.equals(id.with)) {
+ if (isInitiator()) {
+ if (transition(State.PROCEED)) {
+ if (serverMsgId != null) {
+ this.message.setServerMsgId(serverMsgId);
+ }
+ this.message.setTime(timestamp);
+ this.sendSessionInitiate(media, State.SESSION_INITIALIZED_PRE_APPROVED);
+ } else {
+ Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
+ }
+ } else {
+ Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
+ }
+ } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
+ if (transition(State.ACCEPTED)) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
+ this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ this.finish();
+ }
+ } else {
+ Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
+ }
+ }
+
+ private void receiveRetract(final Jid from, final String serverMsgId, final long timestamp) {
+ if (from.equals(id.with)) {
+ if (transition(State.RETRACTED)) {
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted (serverMsgId=" + serverMsgId + ")");
+ if (serverMsgId != null) {
+ this.message.setServerMsgId(serverMsgId);
+ }
+ this.message.setTime(timestamp);
+ writeLogMessageMissed();
+ finish();
+ } else {
+ Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
+ }
+ } else {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
+ }
+ }
+
+ private void sendSessionInitiate(final Set media, final State targetState) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
+ discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers));
+ }
+
+ private synchronized void sendSessionInitiate(final Set media, final State targetState, final List iceServers) {
+ if (TERMINATED.contains(this.state)) {
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": ICE servers got discovered when session was already terminated. nothing to do.");
+ return;
+ }
+ try {
+ setupWebRTC(media, iceServers);
+ } catch (WebRTCWrapper.InitializationException e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize webrtc");
+ transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
+ return;
+ }
+ try {
+ org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
+ final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
+ final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
+ sendSessionInitiate(rtpContentMap, targetState);
+ this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
+ } catch (final Exception e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to sendSessionInitiate", e);
+ webRTCWrapper.close();
+ if (isInState(targetState)) {
+ sendSessionTerminate(Reason.FAILED_APPLICATION);
+ } else {
+ transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
+ }
+ }
+ }
+
+ private void sendSessionInitiate(RtpContentMap rtpContentMap, final State targetState) {
+ this.initiatorRtpContentMap = rtpContentMap;
+ this.transitionOrThrow(targetState);
+ final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
+ send(sessionInitiate);
+ }
+
+ private void sendSessionTerminate(final Reason reason) {
+ sendSessionTerminate(reason, null);
+ }
+
+ private void sendSessionTerminate(final Reason reason, final String text) {
+ final State target = reasonToState(reason);
+ transitionOrThrow(target);
+ writeLogMessage(target);
+ final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
+ jinglePacket.setReason(reason, text);
+ Log.d(Config.LOGTAG, jinglePacket.toString());
+ send(jinglePacket);
+ finish();
+ }
+
+ private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
+ final RtpContentMap transportInfo;
+ try {
+ final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
+ transportInfo = rtpContentMap.transportInfo(contentName, candidate);
+ } catch (Exception e) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
+ return;
+ }
+ final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
+ send(jinglePacket);
+ }
+
+ private void send(final JinglePacket jinglePacket) {
+ jinglePacket.setTo(id.with);
+ xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
+ }
+
+ private synchronized void handleIqResponse(final Account account, final IqPacket response) {
+ if (response.getType() == IqPacket.TYPE.ERROR) {
+ final String errorCondition = response.getErrorCondition();
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ-error from " + response.getFrom() + " in RTP session. " + errorCondition);
+ if (TERMINATED.contains(this.state)) {
+ Log.i(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring error because session was already terminated");
+ return;
+ }
+ this.webRTCWrapper.close();
+ final State target;
+ if (Arrays.asList(
+ "service-unavailable",
+ "recipient-unavailable",
+ "remote-server-not-found",
+ "remote-server-timeout"
+ ).contains(errorCondition)) {
+ target = State.TERMINATED_CONNECTIVITY_ERROR;
+ } else {
+ target = State.TERMINATED_APPLICATION_FAILURE;
+ }
+ transitionOrThrow(target);
+ this.finish();
+ } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ timeout in RTP session with " + id.with + ". terminating with connectivity error");
+ if (TERMINATED.contains(this.state)) {
+ Log.i(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring error because session was already terminated");
+ return;
+ }
+ this.webRTCWrapper.close();
+ transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
+ this.finish();
+ }
+ }
+
+ private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminating session with out-of-order");
+ this.webRTCWrapper.close();
+ transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
+ respondWithOutOfOrder(jinglePacket);
+ this.finish();
+ }
+
+ private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
+ jingleConnectionManager.respondWithJingleError(id.account, jinglePacket, "out-of-order", "unexpected-request", "wait");
+ }
+
+ private void respondOk(final JinglePacket jinglePacket) {
+ xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
+ }
+
+ public RtpEndUserState getEndUserState() {
+ switch (this.state) {
+ case PROPOSED:
+ case SESSION_INITIALIZED:
+ if (isInitiator()) {
+ return RtpEndUserState.RINGING;
+ } else {
+ return RtpEndUserState.INCOMING_CALL;
+ }
+ case PROCEED:
+ if (isInitiator()) {
+ return RtpEndUserState.RINGING;
+ } else {
+ return RtpEndUserState.ACCEPTING_CALL;
+ }
+ case SESSION_INITIALIZED_PRE_APPROVED:
+ if (isInitiator()) {
+ return RtpEndUserState.RINGING;
+ } else {
+ return RtpEndUserState.CONNECTING;
+ }
+ case SESSION_ACCEPTED:
+ final PeerConnection.PeerConnectionState state = webRTCWrapper.getState();
+ if (state == PeerConnection.PeerConnectionState.CONNECTED) {
+ return RtpEndUserState.CONNECTED;
+ } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
+ return RtpEndUserState.CONNECTING;
+ } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
+ return RtpEndUserState.ENDING_CALL;
+ } else {
+ return RtpEndUserState.CONNECTIVITY_ERROR;
+ }
+ case REJECTED:
+ case TERMINATED_DECLINED_OR_BUSY:
+ if (isInitiator()) {
+ return RtpEndUserState.DECLINED_OR_BUSY;
+ } else {
+ return RtpEndUserState.ENDED;
+ }
+ case TERMINATED_SUCCESS:
+ case ACCEPTED:
+ case RETRACTED:
+ case TERMINATED_CANCEL_OR_TIMEOUT:
+ return RtpEndUserState.ENDED;
+ case TERMINATED_CONNECTIVITY_ERROR:
+ return RtpEndUserState.CONNECTIVITY_ERROR;
+ case TERMINATED_APPLICATION_FAILURE:
+ return RtpEndUserState.APPLICATION_ERROR;
+ }
+ throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
+ }
+
+ public Set getMedia() {
+ if (isInState(State.NULL)) {
+ throw new IllegalStateException("RTP connection has not been initialized yet");
+ }
+ if (isInState(State.PROPOSED, State.PROCEED)) {
+ return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
+ }
+ final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
+ if (initiatorContentMap != null) {
+ return initiatorContentMap.getMedia();
+ } else {
+ return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
+ }
+ }
+
+
+ public synchronized void acceptCall() {
+ switch (this.state) {
+ case PROPOSED:
+ cancelRingingTimeout();
+ acceptCallFromProposed();
+ break;
+ case SESSION_INITIALIZED:
+ cancelRingingTimeout();
+ acceptCallFromSessionInitialized();
+ break;
+ case ACCEPTED:
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": the call has already been accepted with another client. UI was just lagging behind");
+ break;
+ case PROCEED:
+ case SESSION_ACCEPTED:
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": the call has already been accepted. user probably double tapped the UI");
+ break;
+ default:
+ throw new IllegalStateException("Can not accept call from " + this.state);
+ }
+ }
+
+ public synchronized void rejectCall() {
+ switch (this.state) {
+ case PROPOSED:
+ rejectCallFromProposed();
+ break;
+ case SESSION_INITIALIZED:
+ rejectCallFromSessionInitiate();
+ break;
+ default:
+ throw new IllegalStateException("Can not reject call from " + this.state);
+ }
+ }
+
+ public synchronized void endCall() {
+ if (TERMINATED.contains(this.state)) {
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": received endCall() when session has already been terminated. nothing to do");
+ return;
+ }
+ if (isInState(State.PROPOSED) && !isInitiator()) {
+ rejectCallFromProposed();
+ return;
+ }
+ if (isInState(State.PROCEED)) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ending call while in state PROCEED just means ending the connection");
+ this.jingleConnectionManager.endSession(id, State.TERMINATED_SUCCESS);
+ this.webRTCWrapper.close();
+ this.finish();
+ transitionOrThrow(State.TERMINATED_SUCCESS); //arguably this wasn't success; but not a real failure either
+ return;
+ }
+ if (isInitiator() && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
+ this.webRTCWrapper.close();
+ sendSessionTerminate(Reason.CANCEL);
+ return;
+ }
+ if (isInState(State.SESSION_INITIALIZED)) {
+ rejectCallFromSessionInitiate();
+ return;
+ }
+ if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
+ this.webRTCWrapper.close();
+ sendSessionTerminate(Reason.SUCCESS);
+ return;
+ }
+ if (isInState(State.TERMINATED_APPLICATION_FAILURE, State.TERMINATED_CONNECTIVITY_ERROR, State.TERMINATED_DECLINED_OR_BUSY)) {
+ Log.d(Config.LOGTAG, "ignoring request to end call because already in state " + this.state);
+ return;
+ }
+ throw new IllegalStateException("called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
+ }
+
+ private void setupWebRTC(final Set media, final List iceServers) throws WebRTCWrapper.InitializationException {
+ this.jingleConnectionManager.ensureConnectionIsRegistered(this);
+ final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference;
+ if (media.contains(Media.VIDEO)) {
+ speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.SPEAKER;
+ } else {
+ speakerPhonePreference = AppRTCAudioManager.SpeakerPhonePreference.EARPIECE;
+ }
+ this.webRTCWrapper.setup(this.xmppConnectionService, speakerPhonePreference);
+ this.webRTCWrapper.initializePeerConnection(media, iceServers);
+ }
+
+ private void acceptCallFromProposed() {
+ transitionOrThrow(State.PROCEED);
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ this.sendJingleMessage("accept", id.account.getJid().asBareJid());
+ this.sendJingleMessage("proceed");
+ }
+
+ private void rejectCallFromProposed() {
+ transitionOrThrow(State.REJECTED);
+ writeLogMessageMissed();
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ this.sendJingleMessage("reject");
+ finish();
+ }
+
+ private void rejectCallFromSessionInitiate() {
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.DECLINE);
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ }
+
+ private void sendJingleMessage(final String action) {
+ sendJingleMessage(action, id.with);
+ }
+
+ private void sendJingleMessage(final String action, final Jid to) {
+ final MessagePacket messagePacket = new MessagePacket();
+ if ("proceed".equals(action)) {
+ messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
+ }
+ messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
+ messagePacket.setTo(to);
+ messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
+ messagePacket.addChild("store", "urn:xmpp:hints");
+ xmppConnectionService.sendMessagePacket(id.account, messagePacket);
+ }
+
+ private void acceptCallFromSessionInitialized() {
+ xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
+ sendSessionAccept();
+ }
+
+ private synchronized boolean isInState(State... state) {
+ return Arrays.asList(state).contains(this.state);
+ }
+
+ private boolean transition(final State target) {
+ return transition(target, null);
+ }
+
+ private synchronized boolean transition(final State target, final Runnable runnable) {
+ final Collection validTransitions = VALID_TRANSITIONS.get(this.state);
+ if (validTransitions != null && validTransitions.contains(target)) {
+ this.state = target;
+ if (runnable != null) {
+ runnable.run();
+ }
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
+ updateEndUserState();
+ updateOngoingCallNotification();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public void transitionOrThrow(final State target) {
+ if (!transition(target)) {
+ throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
+ }
+ }
+
+ @Override
+ public void onIceCandidate(final IceCandidate iceCandidate) {
+ final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
+ Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
+ sendTransportInfo(iceCandidate.sdpMid, candidate);
+ }
+
+ @Override
+ public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
+ if (newState == PeerConnection.PeerConnectionState.CONNECTED && this.rtpConnectionStarted == 0) {
+ this.rtpConnectionStarted = SystemClock.elapsedRealtime();
+ }
+ //TODO 'DISCONNECTED' might be an opportunity to renew the offer and send a transport-replace
+ //TODO exact syntax is yet to be determined but transport-replace sounds like the most reasonable
+ //as there is no content-replace
+ if (Arrays.asList(PeerConnection.PeerConnectionState.FAILED, PeerConnection.PeerConnectionState.DISCONNECTED).contains(newState)) {
+ if (TERMINATED.contains(this.state)) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not sending session-terminate after connectivity error because session is already in state " + this.state);
+ return;
+ }
+ new Thread(this::closeWebRTCSessionAfterFailedConnection).start();
+ } else {
+ updateEndUserState();
+ }
+ }
+
+ private void closeWebRTCSessionAfterFailedConnection() {
+ this.webRTCWrapper.close();
+ synchronized (this) {
+ if (TERMINATED.contains(state)) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": no need to send session-terminate after failed connection. Other party already did");
+ return;
+ }
+ sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
+ }
+ }
+
+ public AppRTCAudioManager getAudioManager() {
+ return webRTCWrapper.getAudioManager();
+ }
+
+ public boolean isMicrophoneEnabled() {
+ return webRTCWrapper.isMicrophoneEnabled();
+ }
+
+ public void setMicrophoneEnabled(final boolean enabled) {
+ webRTCWrapper.setMicrophoneEnabled(enabled);
+ }
+
+ public boolean isVideoEnabled() {
+ return webRTCWrapper.isVideoEnabled();
+ }
+
+ public void setVideoEnabled(final boolean enabled) {
+ webRTCWrapper.setVideoEnabled(enabled);
+ }
+
+ @Override
+ public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set availableAudioDevices) {
+ xmppConnectionService.notifyJingleRtpConnectionUpdate(selectedAudioDevice, availableAudioDevices);
+ }
+
+ private void updateEndUserState() {
+ xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState());
+ }
+
+ private void updateOngoingCallNotification() {
+ if (STATES_SHOWING_ONGOING_CALL.contains(this.state)) {
+ xmppConnectionService.setOngoingCall(id, getMedia());
+ } else {
+ xmppConnectionService.removeOngoingCall();
+ }
+ }
+
+ private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
+ if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
+ final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
+ request.setTo(Jid.of(id.account.getJid().getDomain()));
+ request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
+ xmppConnectionService.sendIqPacket(id.account, request, (account, response) -> {
+ ImmutableList.Builder listBuilder = new ImmutableList.Builder<>();
+ if (response.getType() == IqPacket.TYPE.RESULT) {
+ final Element services = response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
+ final List children = services == null ? Collections.emptyList() : services.getChildren();
+ for (final Element child : children) {
+ if ("service".equals(child.getName())) {
+ final String type = child.getAttribute("type");
+ final String host = child.getAttribute("host");
+ final String sport = child.getAttribute("port");
+ final Integer port = sport == null ? null : Ints.tryParse(sport);
+ final String transport = child.getAttribute("transport");
+ final String username = child.getAttribute("username");
+ final String password = child.getAttribute("password");
+ if (Strings.isNullOrEmpty(host) || port == null) {
+ continue;
+ }
+ if (port < 0 || port > 65535) {
+ continue;
+ }
+ if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type) && Arrays.asList("udp", "tcp").contains(transport)) {
+ if (Arrays.asList("stuns", "turns").contains(type) && "udp".equals(transport)) {
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping invalid combination of udp/tls in external services");
+ continue;
+ }
+ //TODO wrap ipv6 addresses
+ PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer.builder(String.format("%s:%s:%s?transport=%s", type, host, port, transport));
+ if (username != null && password != null) {
+ iceServerBuilder.setUsername(username);
+ iceServerBuilder.setPassword(password);
+ } else if (Arrays.asList("turn", "turns").contains(type)) {
+ //The WebRTC spec requires throwing an InvalidAccessError when username (from libwebrtc source coder)
+ //https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping " + type + "/" + transport + " without username and password");
+ continue;
+ }
+ final PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
+ Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": discovered ICE Server: " + iceServer);
+ listBuilder.add(iceServer);
+ }
+ }
+ }
+ }
+ List iceServers = listBuilder.build();
+ if (iceServers.size() == 0) {
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no ICE server found " + response);
+ }
+ onIceServersDiscovered.onIceServersDiscovered(iceServers);
+ });
+ } else {
+ Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": has no external service discovery");
+ onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
+ }
+ }
+
+ private void finish() {
+ this.cancelRingingTimeout();
+ this.webRTCWrapper.verifyClosed();
+ this.jingleConnectionManager.finishConnection(this);
+ }
+
+ private void writeLogMessage(final State state) {
+ final long started = this.rtpConnectionStarted;
+ long duration = started <= 0 ? 0 : SystemClock.elapsedRealtime() - started;
+ if (state == State.TERMINATED_SUCCESS || (state == State.TERMINATED_CONNECTIVITY_ERROR && duration > 0)) {
+ writeLogMessageSuccess(duration);
+ } else {
+ writeLogMessageMissed();
+ }
+ }
+
+ private void writeLogMessageSuccess(final long duration) {
+ this.message.setBody(new RtpSessionStatus(true, duration).toString());
+ this.writeMessage();
+ }
+
+ private void writeLogMessageMissed() {
+ this.message.setBody(new RtpSessionStatus(false, 0).toString());
+ this.writeMessage();
+ }
+
+ private void writeMessage() {
+ final Conversational conversational = message.getConversation();
+ if (conversational instanceof Conversation) {
+ ((Conversation) conversational).add(this.message);
+ xmppConnectionService.databaseBackend.createMessage(message);
+ xmppConnectionService.updateConversationUi();
+ } else {
+ throw new IllegalStateException("Somehow the conversation in a message was a stub");
+ }
+ }
+
+ public State getState() {
+ return this.state;
+ }
+
+ public Optional geLocalVideoTrack() {
+ return webRTCWrapper.getLocalVideoTrack();
+ }
+
+ public Optional getRemoteVideoTrack() {
+ return webRTCWrapper.getRemoteVideoTrack();
+ }
+
+
+ public EglBase.Context getEglBaseContext() {
+ return webRTCWrapper.getEglBaseContext();
+ }
+
+ public void setProposedMedia(final Set media) {
+ this.proposedMedia = media;
+ }
+
+ private interface OnIceServersDiscovered {
+ void onIceServersDiscovered(List iceServers);
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java
index e6b23ad18..4e7825c42 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java
@@ -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);
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/Media.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/Media.java
new file mode 100644
index 000000000..da25516ca
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/Media.java
@@ -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;
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java
new file mode 100644
index 000000000..67e275414
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java
@@ -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 formats;
+ private String connectionData;
+ private ArrayListMultimap 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 formats) {
+ this.formats = formats;
+ return this;
+ }
+
+ public MediaBuilder setConnectionData(String connectionData) {
+ this.connectionData = connectionData;
+ return this;
+ }
+
+ public MediaBuilder setAttributes(ArrayListMultimap attributes) {
+ this.attributes = attributes;
+ return this;
+ }
+
+ public SessionDescription.Media createMedia() {
+ return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java
new file mode 100644
index 000000000..646137495
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java
@@ -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 BASIC_RTP_REQUIREMENTS = Arrays.asList(
+ Namespace.JINGLE,
+ Namespace.JINGLE_TRANSPORT_ICE_UDP,
+ Namespace.JINGLE_APPS_RTP,
+ Namespace.JINGLE_APPS_DTLS
+ );
+ private static List 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 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
+ }
+
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java
new file mode 100644
index 000000000..da48d017f
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java
@@ -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 contents;
+
+ private RtpContentMap(Group group, Map 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 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 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 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 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 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 of(final Map contents) {
+ return ImmutableMap.copyOf(Maps.transformValues(contents, new Function() {
+ @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);
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java
new file mode 100644
index 000000000..398777cfe
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpEndUserState.java
@@ -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
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java
new file mode 100644
index 000000000..aa33ae41c
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java
@@ -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 attributes;
+ public final List media;
+
+
+ public SessionDescription(int version, String name, String connectionData, ArrayListMultimap attributes, List media) {
+ this.version = version;
+ this.name = name;
+ this.connectionData = connectionData;
+ this.attributes = attributes;
+ this.media = media;
+ }
+
+ private static void appendAttributes(StringBuilder s, ArrayListMultimap attributes) {
+ for (Map.Entry 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 attributeMap = ArrayListMultimap.create();
+ ImmutableList.Builder 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 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 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 attributeMap = ArrayListMultimap.create();
+ final ImmutableList.Builder 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 entry : contentMap.contents.entrySet()) {
+ final String name = entry.getKey();
+ RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue();
+ RtpDescription description = descriptionTransport.description;
+ IceUdpTransportInfo transport = descriptionTransport.transport;
+ final ArrayListMultimap 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 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 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 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 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 formats;
+ public final String connectionData;
+ public final ArrayListMultimap attributes;
+
+ public Media(String media, int port, String protocol, List formats, String connectionData, ArrayListMultimap attributes) {
+ this.media = media;
+ this.port = port;
+ this.protocol = protocol;
+ this.formats = formats;
+ this.connectionData = connectionData;
+ this.attributes = attributes;
+ }
+ }
+
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescriptionBuilder.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescriptionBuilder.java
new file mode 100644
index 000000000..edee2ed76
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescriptionBuilder.java
@@ -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 attributes;
+ private List 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 attributes) {
+ this.attributes = attributes;
+ return this;
+ }
+
+ public SessionDescriptionBuilder setMedia(List media) {
+ this.media = media;
+ return this;
+ }
+
+ public SessionDescription createSessionDescription() {
+ return new SessionDescription(version, name, connectionData, attributes, media);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/Transport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/Transport.java
deleted file mode 100644
index 4d0fb4b65..000000000
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/Transport.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package eu.siacs.conversations.xmpp.jingle;
-
-public enum Transport {
- SOCKS, IBB
-}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java
new file mode 100644
index 000000000..a5fc8429a
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java
@@ -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 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 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, final List 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 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 createOffer() {
+ return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
+ final SettableFuture 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 createAnswer() {
+ return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
+ final SettableFuture 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 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 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 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 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 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 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 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 getLocalVideoTrack() {
+ return Optional.fromNullable(this.localVideoTrack);
+ }
+
+ public Optional 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 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));
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java
index 3696756cf..f27efb1e2 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java
@@ -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);
+ }
}
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java
new file mode 100644
index 000000000..8e0f2ebad
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java
@@ -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 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;
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java
new file mode 100644
index 000000000..a8db0d09f
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java
@@ -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;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericTransportInfo.java
new file mode 100644
index 000000000..4c5c77388
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericTransportInfo.java
@@ -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;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java
new file mode 100644
index 000000000..eb5c32252
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java
@@ -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 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 getIdentificationTags() {
+ final ImmutableList.Builder 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 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;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java
new file mode 100644
index 000000000..90fb32903
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java
@@ -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;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java
new file mode 100644
index 000000000..1e7ada424
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java
@@ -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 getCandidates() {
+ final ImmutableList.Builder 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 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 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 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");
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java
index 53f0fd6cd..31ee95bbf 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java
@@ -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 getJingleContents() {
+ final Element jingle = findChild("jingle", Namespace.JINGLE);
+ ImmutableMap.Builder 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;
+ }
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java
new file mode 100644
index 000000000..da3a93da3
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java
@@ -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 getDescriptions() {
+ final ImmutableList.Builder 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;
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java
index 610d5e760..9e4c8d95d 100644
--- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Reason.java
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java
new file mode 100644
index 000000000..d40363b49
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java
@@ -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 getPayloadTypes() {
+ final ImmutableList.Builder builder = new ImmutableList.Builder<>();
+ for (Element child : getChildren()) {
+ if ("payload-type".equals(child.getName())) {
+ builder.add(PayloadType.of(child));
+ }
+ }
+ return builder.build();
+ }
+
+ public List getFeedbackNegotiations() {
+ return FeedbackNegotiation.fromChildren(this.getChildren());
+ }
+
+ public List feedbackNegotiationTrrInts() {
+ return FeedbackNegotiationTrrInt.fromChildren(this.getChildren());
+ }
+
+ public List getHeaderExtensions() {
+ final ImmutableList.Builder 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 getSources() {
+ final ImmutableList.Builder 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 getSourceGroups() {
+ final ImmutableList.Builder 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 fromChildren(final List