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..86d69fd1f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ * End-to-end encryption with [OMEMO](http://conversations.im/omemo/) or [OpenPGP](http://openpgp.org/about/) * Send and receive images as well as other kind of files +* Make audio and video calls * Share your location * Send voice messages * Indication when your contact has read your message @@ -150,7 +151,7 @@ However you can disable the notification via settings of the operating system. ( **The battery consumption and the entire behaviour of Conversations will remain the same (as good or as bad as it was before). Why is Google doing this to you? We have no idea.** -##### Android <= 7.1 +##### Android <= 7.1 or Conversations from F-Droid (all Android versions) The foreground notification is still controlled over the expert settings within Conversations as it always has been. Whether or not you need to enable it depends on how aggressive the non-standard 'power saving' features are that your phone vendor has built into the operating system. ##### Android 8.x @@ -367,6 +368,12 @@ Unfortunately we 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 @@ -412,7 +419,7 @@ Debian/Ubuntu for example it is called `android-tools-adb`. Furthermore you might have to enable 'USB debugging' in the Developer options of your phone. After that you can just execute the following on your computer: - adb -d logcat -v time -s conversations + adb -d logcat -v time -s conver6ations If need be there are also some Apps on the PlayStore that can be used to show the logcat directly on your rooted phone. (Search for logcat). However in regards to further processing diff --git a/build.gradle b/build.gradle index 88267e831..2aaddce04 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,17 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:2.6.4" implementation "com.squareup.retrofit2:converter-gson:2.6.4" //okhttp needs to stick with 3.12.x - implementation 'com.squareup.okhttp3:okhttp:3.12.7' + implementation 'com.squareup.okhttp3:okhttp:3.12.10' implementation 'com.google.guava:guava:27.1-android' quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.11.1' + //implementation fileTree(include: ['libwebrtc-m81.aar'], dir: 'libs') + implementation 'org.webrtc:google-webrtc:1.0.+' } ext { travisBuild = System.getenv("TRAVIS") == "true" preDexEnabled = System.getProperty("pre-dex", "true") + abiCodes = ['armeabi-v7a': 1, 'x86': 2, 'x86_64': 3, 'arm64-v8a': 4] } android { @@ -90,8 +96,8 @@ android { defaultConfig { minSdkVersion 16 targetSdkVersion 25 - versionCode 367 - versionName "2.7.1" + versionCode 379 + versionName "2.8.0" archivesBaseName += "-$versionName" applicationId "eu.sum7.conversations" resValue "string", "applicationId", applicationId @@ -99,6 +105,7 @@ android { buildConfigField "String", "LOGTAG", "\"conver6ations\"" } + dataBinding { enabled true } @@ -247,4 +254,5 @@ android { exclude 'META-INF/BCKEY.DSA' exclude 'META-INF/BCKEY.SF' } + } diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 000000000..d03cefd88 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,39 @@ +Easy to use, reliable, battery friendly. With built-in support for images, group chats and e2e encryption. + +Design principles: + +* Be as beautiful and easy to use as possible without sacrificing security or privacy +* Rely on existing, well established protocols +* Do not require a Google Account or specifically Google Cloud Messaging (GCM) +* Require as few permissions as possible + +Features: + +* End-to-end encryption with either OMEMO or OpenPGP +* Sending and receiving images +* Make audio and video calls +* Intuitive UI that follows Android Design guidelines +* Pictures / Avatars for your Contacts +* Syncs with desktop client +* Conferences (with support for bookmarks) +* Address book integration +* Multiple accounts / unified inbox +* Very low impact on battery life + +Conversations makes it very easy to create an account on the conversations.im server. Using that server comes with an annual fee of 8 Euro after a 6 month trial period. However Conversations will work with any other XMPP server as well. A lot of XMPP servers are run by volunteers and are free of charge. + +XMPP Features: + +Conversations works with every XMPP server out there. However XMPP is an extensible protocol. These extensions are standardized as well in so called XEP’s. Conversations supports a couple of those to make the overall user experience better. There is a chance that your current XMPP server does not support these extensions. Therefore to get the most out of Conversations you should consider either switching to an XMPP server that does or - even better - run your own XMPP server for you and your friends. + +These XEPs are - as of now: + +* XEP-0065: SOCKS5 Bytestreams (or mod_proxy65). Will be used to transfer files if both parties are behind a firewall (NAT). +* XEP-0163: Personal Eventing Protocol for avatars +* XEP-0191: Blocking command lets you blacklist spammers or block contacts without removing them from your roster. +* XEP-0198: Stream Management allows XMPP to survive small network outages and changes of the underlying TCP connection. +* XEP-0280: Message Carbons which automatically syncs the messages you send to your desktop client and thus allows you to switch seamlessly from your mobile client to your desktop client and back within one conversation. +* XEP-0237: Roster Versioning mainly to save bandwidth on poor mobile connections +* XEP-0313: Message Archive Management synchronize message history with the server. Catch up with messages that were sent while Conversations was offline. +* XEP-0352: Client State Indication lets the server know whether or not Conversations is in the background. Allows the server to save bandwidth by withholding unimportant packages. +* XEP-0363: HTTP File Upload allows you to share files in conferences and with offline contacts. Requires an additional component on your server. 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/metadata/en-US/changelogs/379.txt b/metadata/en-US/changelogs/379.txt new file mode 100644 index 000000000..94284ef6d --- /dev/null +++ b/metadata/en-US/changelogs/379.txt @@ -0,0 +1,2 @@ +• Audio/Video calls (Requires server support in form of STUN and TURN servers discoverable via XEP-0215) +• Rename App to only Conv6sation 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 XMPP Utiliser chat.sum7.eu Cré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..ea7b00e82 100644 --- a/src/conversations/res/values-nl/strings.xml +++ b/src/conversations/res/values-nl/strings.xml @@ -5,4 +5,7 @@ Nieuwe account registreren Heb je al een XMPP-account? Als je al een andere XMPP-cliënt gebruikt, of Conversations vroeger al eens hebt gebruikt, is dit waarschijnlijk het geval. Zo niet, kan je nu een nieuwe XMPP-account aanmaken.\nTip: sommige e-mailproviders bieden ook XMPP-accounts aan. 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. + 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..0244160c5 100644 --- a/src/conversations/res/values-uk/strings.xml +++ b/src/conversations/res/values-uk/strings.xml @@ -5,4 +5,7 @@ Створити новий обліковий запис Вже маєте обліковий запис XMPP? Можливо, користуєтеся іншою програмою XMPP або користувалися цією програмою раніше. Якщо ні, можете створити новий обліковий запис XMPP просто зараз.\nЗверніть увагу: Деякі постачальники електронної пошти водночас надають облікові записи XMPP. XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP сервером, який оберете.\nПроте, для зручності, ми спростили створення облікового запису на chat.sum7.eu — в постачальника, який спеціально налаштований на роботу з цією програмою. + Вас запросили до %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: - *

- *
    - *
  1. Success, if the wrapped defaultVerifier accepts the certificate.
  2. - *
  3. Success, if the server certificate is stored in the keystore under the given hostname.
  4. - *
  5. Ask the user and return accordingly.
  6. - *
  7. Failure on exception.
  8. - *
- * - * @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> + *

    + *
  1. Success, if the wrapped defaultVerifier accepts the certificate.
  2. + *
  3. Success, if the server certificate is stored in the keystore under the given hostname.
  4. + *
  5. Ask the user and return accordingly.
  6. + *
  7. Failure on exception.
  8. + *
+ * + * @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..0e33cde2b 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(); @@ -1936,7 +1995,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke this.binding.textinput.append(this.conversation.getNextMessage()); } this.binding.textinput.setKeyboardListener(this); - messageListAdapter.updatePreferences(); refresh(false); this.conversation.messagesLoaded.set(true); Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + Boolean.toString(scrolledToBottomAndNoPending)); @@ -2740,10 +2798,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke Log.e(Config.LOGTAG, "cleared pending photo uri"); } if (pendingConversationsUuid.clear()) { - Log.e(Config.LOGTAG,"cleared pending conversations uuid"); + Log.e(Config.LOGTAG, "cleared pending conversations uuid"); } if (pendingMediaPreviews.clear()) { - Log.e(Config.LOGTAG,"cleared pending media previews"); + Log.e(Config.LOGTAG, "cleared pending media previews"); } } @@ -2761,7 +2819,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } final PopupMenu popupMenu = new PopupMenu(getActivity(), v); final Contact contact = message.getContact(); - if (message.getStatus() <= Message.STATUS_RECEIVED && (contact == null || !contact.isSelf())) { + if (message.getStatus() <= Message.STATUS_RECEIVED && (contact == null || !contact.isSelf())) { if (message.getConversation().getMode() == Conversation.MODE_MULTI) { final Jid cp = message.getCounterpart(); if (cp == null || cp.isBareJid()) { @@ -2844,4 +2902,4 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } activity.switchToAccount(message.getConversation().getAccount(), fingerprint); } -} \ No newline at end of file +} 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..acd9d151e --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java @@ -0,0 +1,840 @@ +package eu.siacs.conversations.ui; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.PictureInPictureParams; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.databinding.DataBindingUtil; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.support.annotation.RequiresApi; +import android.support.annotation.StringRes; +import android.util.Log; +import android.util.Rational; +import android.view.View; +import android.view.WindowManager; +import android.widget.Toast; + +import com.google.common.base.Optional; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoTrack; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ActivityRtpSessionBinding; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.services.AppRTCAudioManager; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.util.AvatarWorkerTask; +import eu.siacs.conversations.utils.PermissionUtils; +import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; +import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection; +import eu.siacs.conversations.xmpp.jingle.Media; +import eu.siacs.conversations.xmpp.jingle.RtpEndUserState; +import rocks.xmpp.addr.Jid; + +import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied; +import static java.util.Arrays.asList; + +public class RtpSessionActivity extends XmppActivity implements XmppConnectionService.OnJingleRtpConnectionUpdate { + + public static final String EXTRA_WITH = "with"; + public static final String EXTRA_SESSION_ID = "session_id"; + public static final String EXTRA_LAST_REPORTED_STATE = "last_reported_state"; + public static final String EXTRA_LAST_ACTION = "last_action"; + public static final String ACTION_ACCEPT_CALL = "action_accept_call"; + public static final String ACTION_MAKE_VOICE_CALL = "action_make_voice_call"; + public static final String ACTION_MAKE_VIDEO_CALL = "action_make_video_call"; + private static final List 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()) { + startPictureInPicture(); + } + } + } + + + @RequiresApi(api = Build.VERSION_CODES.O) + private void startPictureInPicture() { + try { + enterPictureInPictureMode( + new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(10, 16)) + .build() + ); + } catch (IllegalStateException e) { + //this sometimes happens on Samsung phones (possibly when Knox is enabled) + Log.w(Config.LOGTAG, "unable to enter picture in picture mode", e); + } + } + + private boolean deviceSupportsPictureInPicture() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); + } else { + return false; + } + } + + private boolean shouldBePictureInPicture() { + try { + final JingleRtpConnection rtpConnection = requireRtpConnection(); + return rtpConnection.getMedia().contains(Media.VIDEO) && Arrays.asList( + RtpEndUserState.ACCEPTING_CALL, + RtpEndUserState.CONNECTING, + RtpEndUserState.CONNECTED + ).contains(rtpConnection.getEndUserState()); + } catch (IllegalStateException e) { + return false; + } + } + + private boolean initializeActivityWithRunningRtpSession(final Account account, Jid with, String sessionId) { + final WeakReference 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; + } + runOnUiThread(() -> { + updateStateDisplay(state); + updateButtonConfiguration(state); + updateVideoViews(state); + updateProfilePicture(state); + }); + if (END_CARD.contains(state)) { + resetIntent(account, with, state, requireRtpConnection().getMedia()); + this.rtpConnectionReference = null; + } + } else { + Log.d(Config.LOGTAG, "received update for other rtp session"); + } + } + + @Override + public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set 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..3073aa55b 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,946 @@ 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(); + } + 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.orange700_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 + ? (type == SENT ? R.color.black26 : R.color.grey800) : 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(); - } - - public void updatePreferences() { - SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity); - } + 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 setHighlightedTerm(List terms) { - this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms); - } + public void setHighlightedTerm(List terms) { + this.highlightedTerm = terms == null ? null : StylingHelper.filterHighlightedWords(terms); + } - public interface OnQuoteListener { - void onQuote(String text); - } + public interface OnQuoteListener { + void onQuote(String text); + } - public interface OnContactPictureClicked { - void onContactPictureClicked(Message message); - } + public interface OnContactPictureClicked { + void onContactPictureClicked(Message message); + } - public interface OnContactPictureLongClicked { - void onContactPictureLongClicked(View v, Message message); - } + public interface OnContactPictureLongClicked { + void onContactPictureLongClicked(View v, Message message); + } - private static class ViewHolder { + 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 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; + } - private class MessageBodyActionModeCallback implements ActionMode.Callback { + private class MessageBodyActionModeCallback implements ActionMode.Callback { - private final TextView textView; + private final TextView textView; - public MessageBodyActionModeCallback(TextView textView) { - this.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 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 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 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..4c410a5be 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,363 @@ package eu.siacs.conversations.xmpp.jingle; -import android.annotation.SuppressLint; +import android.os.SystemClock; +import android.util.Base64; import android.util.Log; -import java.math.BigInteger; +import com.google.common.base.Function; +import com.google.common.base.Objects; +import com.google.common.base.Preconditions; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableSet; + +import org.checkerframework.checker.nullness.compatqual.NullableDecl; + +import java.lang.ref.WeakReference; import java.security.SecureRandom; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.RtpSessionStatus; import eu.siacs.conversations.entities.Transferable; import eu.siacs.conversations.services.AbstractConnectionManager; import eu.siacs.conversations.services.XmppConnectionService; -import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.Propose; +import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import eu.siacs.conversations.xmpp.stanzas.MessagePacket; import rocks.xmpp.addr.Jid; public class JingleConnectionManager extends AbstractConnectionManager { - private List 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) { + if (((JingleRtpConnection) connection).isTerminated()) { + continue; + } + return true; + } + } + synchronized (this.rtpSessionProposals) { + return this.rtpSessionProposals.containsValue(DeviceDiscoveryState.DISCOVERED) || this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING); + } + } + + private boolean isWithStrangerAndStrangerNotificationsAreOff(final Account account, Jid with) { + final boolean notifyForStrangers = mXmppConnectionService.getNotificationService().notificationsFromStrangers(); + if (notifyForStrangers) { + return false; + } + final Contact contact = account.getRoster().getContact(with); + return !contact.showInContactList(); + } + + ScheduledFuture schedule(final Runnable runnable, final long delay, final TimeUnit timeUnit) { + return this.scheduledExecutorService.schedule(runnable, delay, timeUnit); + } + + void respondWithJingleError(final Account account, final IqPacket original, String jingleCondition, String condition, String conditionType) { + final IqPacket response = original.generateResponse(IqPacket.TYPE.ERROR); + final Element error = response.addChild("error"); + error.setAttribute("type", conditionType); + error.addChild(condition, "urn:ietf:params:xml:ns:xmpp-stanzas"); + error.addChild(jingleCondition, "urn:xmpp:jingle:errors:1"); + account.getXmppConnection().sendIqPacket(response, null); + } + + public void deliverMessage(final Account account, final Jid to, final Jid from, final Element message, String serverMsgId, long timestamp) { + Preconditions.checkArgument(Namespace.JINGLE_MESSAGE.equals(message.getNamespace())); + final String sessionId = message.getAttribute("id"); + if (sessionId == null) { + return; + } + if ("accept".equals(message.getName())) { + for (AbstractJingleConnection connection : connections.values()) { + if (connection instanceof JingleRtpConnection) { + final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + final AbstractJingleConnection.Id id = connection.getId(); + if (id.account == account && id.sessionId.equals(sessionId)) { + rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp); + return; + } + } + } + return; + } + final boolean fromSelf = from.asBareJid().equals(account.getJid().asBareJid()); + final AbstractJingleConnection.Id id; + if (fromSelf) { + if (to != null && to.isFullJid()) { + id = AbstractJingleConnection.Id.of(account, to, sessionId); + } else { + return; + } + } else { + id = AbstractJingleConnection.Id.of(account, from, sessionId); + } + final AbstractJingleConnection existingJingleConnection = connections.get(id); + if (existingJingleConnection != null) { + if (existingJingleConnection instanceof JingleRtpConnection) { + ((JingleRtpConnection) existingJingleConnection).deliveryMessage(from, message, serverMsgId, timestamp); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + existingJingleConnection.getClass().getName() + " does not support jingle messages"); + } + return; + } + + if (fromSelf) { + if ("proceed".equals(message.getName())) { + final Conversation c = mXmppConnectionService.findOrCreateConversation(account, id.with, false, false); + final Message previousBusy = c.findRtpSession(sessionId, Message.STATUS_RECEIVED); + if (previousBusy != null) { + previousBusy.setBody(new RtpSessionStatus(true, 0).toString()); + if (serverMsgId != null) { + previousBusy.setServerMsgId(serverMsgId); + } + previousBusy.setTime(timestamp); + mXmppConnectionService.updateMessage(previousBusy, true); + Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": updated previous busy because call got picked up by another device"); return; } } - Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet); - IqPacket response = packet.generateResponse(IqPacket.TYPE.ERROR); - Element error = response.addChild("error"); - error.setAttribute("type", "cancel"); - error.addChild("item-not-found", - "urn:ietf:params:xml:ns:xmpp-stanzas"); - error.addChild("unknown-session", "urn:xmpp:jingle:errors:1"); - account.getXmppConnection().sendIqPacket(response, null); + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignore jingle message from self"); + return; + } + + if ("propose".equals(message.getName())) { + final Propose propose = Propose.upgrade(message); + final List 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 +372,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { @Override public void onIqPacketReceived(Account account, IqPacket packet) { - Element streamhost = packet.query().findChild("streamhost", Namespace.BYTE_STREAMS); + final Element streamhost = packet.query().findChild("streamhost", Namespace.BYTE_STREAMS); final String host = streamhost == null ? null : streamhost.getAttribute("host"); final String port = streamhost == null ? null : streamhost.getAttribute("port"); if (host != null && port != null) { @@ -112,7 +387,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { listener.onPrimaryCandidateFound(true, candidate); } catch (final NumberFormatException e) { listener.onPrimaryCandidateFound(false, null); - return; } } else { listener.onPrimaryCandidateFound(false, null); @@ -129,31 +403,85 @@ public class JingleConnectionManager extends AbstractConnectionManager { } } - public String nextRandomId() { - return new BigInteger(50, random).toString(32); + public void retractSessionProposal(final Account account, final Jid with) { + synchronized (this.rtpSessionProposals) { + RtpSessionProposal matchingProposal = null; + for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) { + if (proposal.account == account && with.asBareJid().equals(proposal.with)) { + matchingProposal = proposal; + break; + } + } + if (matchingProposal != null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retracting rtp session proposal with " + with); + this.rtpSessionProposals.remove(matchingProposal); + final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionRetract(matchingProposal); + writeLogMissedOutgoing(account, matchingProposal.with, matchingProposal.sessionId, null, System.currentTimeMillis()); + mXmppConnectionService.sendMessagePacket(account, messagePacket); + } + } + } + + public void proposeJingleRtpSession(final Account account, final Jid with, final Set media) { + 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 +491,157 @@ public class JingleConnectionManager extends AbstractConnectionManager { account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); } - public void cancelInTransmission() { - for (JingleConnection connection : this.connections) { - if (connection.getJingleStatus() == JingleConnection.JINGLE_STATUS_TRANSMITTING) { - connection.abort("connectivity-error"); + public void notifyRebound() { + for (final AbstractJingleConnection connection : this.connections.values()) { + connection.notifyRebound(); + } + } + + public WeakReference 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 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..0052228b1 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -0,0 +1,1163 @@ +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 = 30; + + 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 (isTerminated()) { + 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 (isTerminated()) { + 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 (isTerminated()) { + 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 (isTerminated()) { + 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 (isTerminated()) { + 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 (isTerminated()) { + 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; + } + } + + 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 (isTerminated()) { + 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 (isTerminated()) { + 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 boolean isTerminated() { + return TERMINATED.contains(this.state); + } + + public Optional geLocalVideoTrack() { + return webRTCWrapper.getLocalVideoTrack(); + } + + public Optional getRemoteVideoTrack() { + return webRTCWrapper.getRemoteVideoTrack(); + } + + + public EglBase.Context getEglBaseContext() { + return webRTCWrapper.getEglBaseContext(); + } + + 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 children) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (final Element child : children) { + if ("rtcp-fb".equals(child.getName()) && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) { + builder.add(upgrade(child)); + } + } + return builder.build(); + } + + } + + public static class FeedbackNegotiationTrrInt extends Element { + + private FeedbackNegotiationTrrInt(int value) { + super("rtcp-fb-trr-int", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION); + this.setAttribute("value", value); + } + + + private FeedbackNegotiationTrrInt() { + super("rtcp-fb-trr-int", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION); + } + + public int getValue() { + final String value = getAttribute("value"); + return Integer.parseInt(value); + + } + + private static FeedbackNegotiationTrrInt upgrade(final Element element) { + Preconditions.checkArgument("rtcp-fb-trr-int".equals(element.getName())); + Preconditions.checkArgument(Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace())); + final FeedbackNegotiationTrrInt trr = new FeedbackNegotiationTrrInt(); + trr.setAttributes(element.getAttributes()); + trr.setChildren(element.getChildren()); + return trr; + } + + public static List fromChildren(final List children) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (final Element child : children) { + if ("rtcp-fb-trr-int".equals(child.getName()) && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) { + builder.add(upgrade(child)); + } + } + return builder.build(); + } + } + + + //XEP-0294: Jingle RTP Header Extensions Negotiation + //maps to `extmap:$id $uri` + public static class RtpHeaderExtension extends Element { + + private RtpHeaderExtension() { + super("rtp-hdrext", Namespace.JINGLE_RTP_HEADER_EXTENSIONS); + } + + public RtpHeaderExtension(String id, String uri) { + super("rtp-hdrext", Namespace.JINGLE_RTP_HEADER_EXTENSIONS); + this.setAttribute("id", id); + this.setAttribute("uri", uri); + } + + public String getId() { + return this.getAttribute("id"); + } + + public String getUri() { + return this.getAttribute("uri"); + } + + public static RtpHeaderExtension upgrade(final Element element) { + Preconditions.checkArgument("rtp-hdrext".equals(element.getName())); + Preconditions.checkArgument(Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(element.getNamespace())); + final RtpHeaderExtension extension = new RtpHeaderExtension(); + extension.setAttributes(element.getAttributes()); + extension.setChildren(element.getChildren()); + return extension; + } + + public static RtpHeaderExtension ofSdpString(final String sdp) { + final String[] pair = sdp.split(" ", 2); + if (pair.length == 2) { + final String id = pair[0]; + final String uri = pair[1]; + return new RtpHeaderExtension(id, uri); + } else { + return null; + } + } + } + + //maps to `rtpmap:$id $name/$clockrate/$channels` + public static class PayloadType extends Element { + + private PayloadType() { + super("payload-type", Namespace.JINGLE_APPS_RTP); + } + + public PayloadType(String id, String name, int clockRate, int channels) { + super("payload-type", Namespace.JINGLE_APPS_RTP); + this.setAttribute("id", id); + this.setAttribute("name", name); + this.setAttribute("clockrate", clockRate); + if (channels != 1) { + this.setAttribute("channels", channels); + } + } + + public String toSdpAttribute() { + final int channels = getChannels(); + final String name = getPayloadTypeName(); + Preconditions.checkArgument(name != null, "Payload-type name must not be empty"); + SessionDescription.checkNoWhitespace(name, "payload-type name must not contain whitespaces"); + return getId() + " " + name + "/" + getClockRate() + (channels == 1 ? "" : "/" + channels); + } + + public int getIntId() { + final String id = this.getAttribute("id"); + return id == null ? 0 : SessionDescription.ignorantIntParser(id); + } + + public String getId() { + return this.getAttribute("id"); + } + + + public String getPayloadTypeName() { + return this.getAttribute("name"); + } + + public int getClockRate() { + final String clockRate = this.getAttribute("clockrate"); + if (clockRate == null) { + return 0; + } + try { + return Integer.parseInt(clockRate); + } catch (NumberFormatException e) { + return 0; + } + } + + public int getChannels() { + final String channels = this.getAttribute("channels"); + if (channels == null) { + return 1; // The number of channels; if omitted, it MUST be assumed to contain one channel + } + try { + return Integer.parseInt(channels); + } catch (NumberFormatException e) { + return 1; + } + } + + public List getParameters() { + final ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (Element child : getChildren()) { + if ("parameter".equals(child.getName())) { + builder.add(Parameter.of(child)); + } + } + return builder.build(); + } + + public List getFeedbackNegotiations() { + return FeedbackNegotiation.fromChildren(this.getChildren()); + } + + public List feedbackNegotiationTrrInts() { + return FeedbackNegotiationTrrInt.fromChildren(this.getChildren()); + } + + public static PayloadType of(final Element element) { + Preconditions.checkArgument("payload-type".equals(element.getName()), "element name must be called payload-type"); + PayloadType payloadType = new PayloadType(); + payloadType.setAttributes(element.getAttributes()); + payloadType.setChildren(element.getChildren()); + return payloadType; + } + + public static PayloadType ofSdpString(final String sdp) { + final String[] pair = sdp.split(" ", 2); + if (pair.length == 2) { + final String id = pair[0]; + final String[] parts = pair[1].split("/"); + if (parts.length >= 2) { + final String name = parts[0]; + final int clockRate = SessionDescription.ignorantIntParser(parts[1]); + final int channels; + if (parts.length >= 3) { + channels = SessionDescription.ignorantIntParser(parts[2]); + } else { + channels = 1; + } + return new PayloadType(id, name, clockRate, channels); + } + } + return null; + } + + public void addChildren(final List children) { + if (children != null) { + this.children.addAll(children); + } + } + + public void addParameters(List parameters) { + if (parameters != null) { + this.children.addAll(parameters); + } + } + } + + //map to `fmtp $id key=value;key=value + //where id is the id of the parent payload-type + public static class Parameter extends Element { + + private Parameter() { + super("parameter", Namespace.JINGLE_APPS_RTP); + } + + public Parameter(String name, String value) { + super("parameter", Namespace.JINGLE_APPS_RTP); + this.setAttribute("name", name); + this.setAttribute("value", value); + } + + public String getParameterName() { + return this.getAttribute("name"); + } + + public String getParameterValue() { + return this.getAttribute("value"); + } + + public static Parameter of(final Element element) { + Preconditions.checkArgument("parameter".equals(element.getName()), "element name must be called parameter"); + Parameter parameter = new Parameter(); + parameter.setAttributes(element.getAttributes()); + parameter.setChildren(element.getChildren()); + return parameter; + } + + public static String toSdpString(final String id, List parameters) { + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(id).append(' '); + for (int i = 0; i < parameters.size(); ++i) { + Parameter p = parameters.get(i); + final String name = p.getParameterName(); + Preconditions.checkArgument(name != null, String.format("parameter for %s must have a name", id)); + SessionDescription.checkNoWhitespace(name, String.format("parameter names for %s must not contain whitespaces", id)); + + final String value = p.getParameterValue(); + Preconditions.checkArgument(value != null, String.format("parameter for %s must have a value", id)); + SessionDescription.checkNoWhitespace(value, String.format("parameter values for %s must not contain whitespaces", id)); + + stringBuilder.append(name).append('=').append(value); + if (i != parameters.size() - 1) { + stringBuilder.append(';'); + } + } + return stringBuilder.toString(); + } + + public static Pair> ofSdpString(final String sdp) { + final String[] pair = sdp.split(" "); + if (pair.length == 2) { + final String id = pair[0]; + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (final String parameter : pair[1].split(";")) { + final String[] parts = parameter.split("=", 2); + if (parts.length == 2) { + builder.add(new Parameter(parts[0], parts[1])); + } + } + return new Pair<>(id, builder.build()); + } else { + return null; + } + } + } + + //XEP-0339: Source-Specific Media Attributes in Jingle + //maps to `a=ssrc: :` + public static class Source extends Element { + + private Source() { + super("source", Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES); + } + + public Source(String ssrcId, Collection parameters) { + super("source", Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES); + this.setAttribute("ssrc", ssrcId); + for (Parameter parameter : parameters) { + this.addChild(parameter); + } + } + + public String getSsrcId() { + return this.getAttribute("ssrc"); + } + + public List getParameters() { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (Element child : this.children) { + if ("parameter".equals(child.getName())) { + builder.add(Parameter.upgrade(child)); + } + } + return builder.build(); + } + + public static Source upgrade(final Element element) { + Preconditions.checkArgument("source".equals(element.getName())); + Preconditions.checkArgument(Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(element.getNamespace())); + final Source source = new Source(); + source.setChildren(element.getChildren()); + source.setAttributes(element.getAttributes()); + return source; + } + + public static class Parameter extends Element { + + public String getParameterName() { + return this.getAttribute("name"); + } + + public String getParameterValue() { + return this.getAttribute("value"); + } + + private Parameter() { + super("parameter"); + } + + public Parameter(final String attribute, final String value) { + super("parameter"); + this.setAttribute("name", attribute); + if (value != null) { + this.setAttribute("value", value); + } + } + + public static Parameter upgrade(final Element element) { + Preconditions.checkArgument("parameter".equals(element.getName())); + Parameter parameter = new Parameter(); + parameter.setAttributes(element.getAttributes()); + parameter.setChildren(element.getChildren()); + return parameter; + } + } + + } + + public static class SourceGroup extends Element { + + public SourceGroup(final String semantics, List ssrcs) { + this(); + this.setAttribute("semantics", semantics); + for (String ssrc : ssrcs) { + this.addChild("source").setAttribute("ssrc", ssrc); + } + } + + private SourceGroup() { + super("ssrc-group", Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES); + } + + public String getSemantics() { + return this.getAttribute("semantics"); + } + + public List getSsrcs() { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + for (Element child : this.children) { + if ("source".equals(child.getName())) { + final String ssrc = child.getAttribute("ssrc"); + if (ssrc != null) { + builder.add(ssrc); + } + } + } + return builder.build(); + } + + public static SourceGroup upgrade(final Element element) { + Preconditions.checkArgument("ssrc-group".equals(element.getName())); + Preconditions.checkArgument(Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(element.getNamespace())); + final SourceGroup group = new SourceGroup(); + group.setChildren(element.getChildren()); + group.setAttributes(element.getAttributes()); + return group; + } + } + + public static RtpDescription of(final SessionDescription.Media media) { + final RtpDescription rtpDescription = new RtpDescription(media.media); + final Map> parameterMap = new HashMap<>(); + final ArrayListMultimap feedbackNegotiationMap = ArrayListMultimap.create(); + final ArrayListMultimap sourceParameterMap = ArrayListMultimap.create(); + for (final String rtcpFb : media.attributes.get("rtcp-fb")) { + final String[] parts = rtcpFb.split(" "); + if (parts.length >= 2) { + final String id = parts[0]; + final String type = parts[1]; + final String subType = parts.length >= 3 ? parts[2] : null; + if ("trr-int".equals(type)) { + if (subType != null) { + feedbackNegotiationMap.put(id, new FeedbackNegotiationTrrInt(SessionDescription.ignorantIntParser(subType))); + } + } else { + feedbackNegotiationMap.put(id, new FeedbackNegotiation(type, subType)); + } + } + } + for (final String ssrc : media.attributes.get(("ssrc"))) { + final String[] parts = ssrc.split(" ", 2); + if (parts.length == 2) { + final String id = parts[0]; + final String[] subParts = parts[1].split(":", 2); + final String attribute = subParts[0]; + final String value = subParts.length == 2 ? subParts[1] : null; + sourceParameterMap.put(id, new Source.Parameter(attribute, value)); + } + } + for (final String fmtp : media.attributes.get("fmtp")) { + final Pair> pair = Parameter.ofSdpString(fmtp); + if (pair != null) { + parameterMap.put(pair.first, pair.second); + } + } + rtpDescription.addChildren(feedbackNegotiationMap.get("*")); + for (final String rtpmap : media.attributes.get("rtpmap")) { + final PayloadType payloadType = PayloadType.ofSdpString(rtpmap); + if (payloadType != null) { + payloadType.addParameters(parameterMap.get(payloadType.getId())); + payloadType.addChildren(feedbackNegotiationMap.get(payloadType.getId())); + rtpDescription.addChild(payloadType); + } + } + for (final String extmap : media.attributes.get("extmap")) { + final RtpHeaderExtension extension = RtpHeaderExtension.ofSdpString(extmap); + if (extension != null) { + rtpDescription.addChild(extension); + } + } + for (final String ssrcGroup : media.attributes.get("ssrc-group")) { + final String[] parts = ssrcGroup.split(" "); + if (parts.length >= 2) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + final String semantics = parts[0]; + for (int i = 1; i < parts.length; ++i) { + builder.add(parts[i]); + } + rtpDescription.addChild(new SourceGroup(semantics, builder.build())); + } + } + for (Map.Entry> source : sourceParameterMap.asMap().entrySet()) { + rtpDescription.addChild(new Source(source.getKey(), source.getValue())); + } + if (media.attributes.containsKey("rtcp-mux")) { + rtpDescription.addChild("rtcp-mux"); + } + return rtpDescription; + } + + private void addChildren(List elements) { + if (elements != null) { + this.children.addAll(elements); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java new file mode 100644 index 000000000..8f8f13416 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java @@ -0,0 +1,50 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import com.google.common.base.Preconditions; + +import java.util.Collection; +import java.util.List; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.JingleCandidate; + +public class S5BTransportInfo extends GenericTransportInfo { + + private S5BTransportInfo(final String name, final String xmlns) { + super(name, xmlns); + } + + public String getTransportId() { + return this.getAttribute("sid"); + } + + public S5BTransportInfo(final String transportId, final Collection candidates) { + super("transport", Namespace.JINGLE_TRANSPORTS_S5B); + Preconditions.checkNotNull(transportId,"transport id must not be null"); + for(JingleCandidate candidate : candidates) { + this.addChild(candidate.toElement()); + } + this.setAttribute("sid", transportId); + } + + public S5BTransportInfo(final String transportId, final Element child) { + super("transport", Namespace.JINGLE_TRANSPORTS_S5B); + Preconditions.checkNotNull(transportId,"transport id must not be null"); + this.addChild(child); + this.setAttribute("sid", transportId); + } + + public List getCandidates() { + return JingleCandidate.parse(this.getChildren()); + } + + public static S5BTransportInfo upgrade(final Element element) { + Preconditions.checkArgument("transport".equals(element.getName()), "Name of provided element is not transport"); + Preconditions.checkArgument(Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), "Element does not match s5b transport namespace"); + final S5BTransportInfo transportInfo = new S5BTransportInfo("transport", Namespace.JINGLE_TRANSPORTS_S5B); + transportInfo.setAttributes(element.getAttributes()); + transportInfo.setChildren(element.getChildren()); + return transportInfo; + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractAcknowledgeableStanza.java b/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractAcknowledgeableStanza.java index 095075616..552f40598 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractAcknowledgeableStanza.java +++ b/src/main/java/eu/siacs/conversations/xmpp/stanzas/AbstractAcknowledgeableStanza.java @@ -31,6 +31,18 @@ abstract public class AbstractAcknowledgeableStanza extends AbstractStanza { return null; } + public String getErrorCondition() { + Element error = findChild("error"); + if (error != null) { + for(Element element : error.getChildren()) { + if (!element.getName().equals("text")) { + return element.getName(); + } + } + } + return null; + } + public boolean valid() { return InvalidJid.isValid(getFrom()) && InvalidJid.isValid(getTo()); } diff --git a/src/main/res/drawable-hdpi/ic_bluetooth_audio_black_24dp.png b/src/main/res/drawable-hdpi/ic_bluetooth_audio_black_24dp.png new file mode 100644 index 000000000..14a5a5584 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_bluetooth_audio_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_black_24dp.png b/src/main/res/drawable-hdpi/ic_call_black_24dp.png new file mode 100644 index 000000000..d4077acf9 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_end_white_48dp.png b/src/main/res/drawable-hdpi/ic_call_end_white_48dp.png new file mode 100644 index 000000000..e1831d7af Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_end_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_made_black_18dp.png b/src/main/res/drawable-hdpi/ic_call_made_black_18dp.png new file mode 100644 index 000000000..1ef5b84e9 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_made_black_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_made_white_18dp.png b/src/main/res/drawable-hdpi/ic_call_made_white_18dp.png new file mode 100644 index 000000000..1b126d2dc Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_made_white_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_missed_black_18dp.png b/src/main/res/drawable-hdpi/ic_call_missed_black_18dp.png new file mode 100644 index 000000000..5447aedc8 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_missed_black_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_missed_outgoing_black_18dp.png b/src/main/res/drawable-hdpi/ic_call_missed_outgoing_black_18dp.png new file mode 100644 index 000000000..7cba4f0ac Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_missed_outgoing_black_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_missed_outgoing_white_18dp.png b/src/main/res/drawable-hdpi/ic_call_missed_outgoing_white_18dp.png new file mode 100644 index 000000000..596012560 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_missed_outgoing_white_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_missed_white_18dp.png b/src/main/res/drawable-hdpi/ic_call_missed_white_18dp.png new file mode 100644 index 000000000..81a7aa16c Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_missed_white_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_received_black_18dp.png b/src/main/res/drawable-hdpi/ic_call_received_black_18dp.png new file mode 100644 index 000000000..af45ceb84 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_received_black_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_received_white_18dp.png b/src/main/res/drawable-hdpi/ic_call_received_white_18dp.png new file mode 100644 index 000000000..8080c7879 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_received_white_18dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_white_24dp.png b/src/main/res/drawable-hdpi/ic_call_white_24dp.png new file mode 100644 index 000000000..4dc506515 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_call_white_48dp.png b/src/main/res/drawable-hdpi/ic_call_white_48dp.png new file mode 100644 index 000000000..90ead2e45 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_call_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_clear_white_48dp.png b/src/main/res/drawable-hdpi/ic_clear_white_48dp.png new file mode 100644 index 000000000..6b717e0dd Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_clear_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_headset_black_24dp.png b/src/main/res/drawable-hdpi/ic_headset_black_24dp.png new file mode 100644 index 000000000..38eb219ef Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_headset_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mic_black_24dp.png b/src/main/res/drawable-hdpi/ic_mic_black_24dp.png new file mode 100644 index 000000000..74218e1a6 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mic_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png new file mode 100644 index 000000000..1755dbf3f Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_mic_off_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_replay_white_48dp.png b/src/main/res/drawable-hdpi/ic_replay_white_48dp.png new file mode 100644 index 000000000..fcddcf02d Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_replay_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_videocam_black_24dp.png b/src/main/res/drawable-hdpi/ic_videocam_black_24dp.png new file mode 100644 index 000000000..0a1e9b08a Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_videocam_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_videocam_off_black_24dp.png b/src/main/res/drawable-hdpi/ic_videocam_off_black_24dp.png new file mode 100644 index 000000000..06140f119 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_videocam_off_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_videocam_white_24dp.png b/src/main/res/drawable-hdpi/ic_videocam_white_24dp.png new file mode 100644 index 000000000..d83e0d50c Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_videocam_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png new file mode 100644 index 000000000..f1326ba7c Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_volume_off_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_volume_up_black_24dp.png b/src/main/res/drawable-hdpi/ic_volume_up_black_24dp.png new file mode 100644 index 000000000..159d9c278 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_volume_up_black_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_warning_white_48dp.png b/src/main/res/drawable-hdpi/ic_warning_white_48dp.png new file mode 100644 index 000000000..a88896590 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_warning_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_bluetooth_audio_black_24dp.png b/src/main/res/drawable-mdpi/ic_bluetooth_audio_black_24dp.png new file mode 100644 index 000000000..5feb0c03a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_bluetooth_audio_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_black_24dp.png b/src/main/res/drawable-mdpi/ic_call_black_24dp.png new file mode 100644 index 000000000..55ed026bc Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_end_white_48dp.png b/src/main/res/drawable-mdpi/ic_call_end_white_48dp.png new file mode 100644 index 000000000..a4fe6889d Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_end_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_made_black_18dp.png b/src/main/res/drawable-mdpi/ic_call_made_black_18dp.png new file mode 100644 index 000000000..e7293a0a9 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_made_black_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_made_white_18dp.png b/src/main/res/drawable-mdpi/ic_call_made_white_18dp.png new file mode 100644 index 000000000..3c6a23529 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_made_white_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_missed_black_18dp.png b/src/main/res/drawable-mdpi/ic_call_missed_black_18dp.png new file mode 100644 index 000000000..fb61875a1 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_missed_black_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_missed_outgoing_black_18dp.png b/src/main/res/drawable-mdpi/ic_call_missed_outgoing_black_18dp.png new file mode 100644 index 000000000..ef4ad9622 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_missed_outgoing_black_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_missed_outgoing_white_18dp.png b/src/main/res/drawable-mdpi/ic_call_missed_outgoing_white_18dp.png new file mode 100644 index 000000000..8fe42b28c Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_missed_outgoing_white_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_missed_white_18dp.png b/src/main/res/drawable-mdpi/ic_call_missed_white_18dp.png new file mode 100644 index 000000000..4d6d77962 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_missed_white_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_received_black_18dp.png b/src/main/res/drawable-mdpi/ic_call_received_black_18dp.png new file mode 100644 index 000000000..fe45b5517 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_received_black_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_received_white_18dp.png b/src/main/res/drawable-mdpi/ic_call_received_white_18dp.png new file mode 100644 index 000000000..9accbbf36 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_received_white_18dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_white_24dp.png b/src/main/res/drawable-mdpi/ic_call_white_24dp.png new file mode 100644 index 000000000..77f9de5e3 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_call_white_48dp.png b/src/main/res/drawable-mdpi/ic_call_white_48dp.png new file mode 100644 index 000000000..ef45e933a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_call_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_clear_white_48dp.png b/src/main/res/drawable-mdpi/ic_clear_white_48dp.png new file mode 100644 index 000000000..b7c7ffd0e Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_clear_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_headset_black_24dp.png b/src/main/res/drawable-mdpi/ic_headset_black_24dp.png new file mode 100644 index 000000000..d872b05d5 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_headset_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mic_black_24dp.png b/src/main/res/drawable-mdpi/ic_mic_black_24dp.png new file mode 100644 index 000000000..19a16138f Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mic_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-mdpi/ic_mic_off_black_24dp.png new file mode 100644 index 000000000..da605a5a1 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_mic_off_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_replay_white_48dp.png b/src/main/res/drawable-mdpi/ic_replay_white_48dp.png new file mode 100644 index 000000000..3b4191325 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_replay_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_videocam_black_24dp.png b/src/main/res/drawable-mdpi/ic_videocam_black_24dp.png new file mode 100644 index 000000000..0722a6929 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_videocam_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_videocam_off_black_24dp.png b/src/main/res/drawable-mdpi/ic_videocam_off_black_24dp.png new file mode 100644 index 000000000..c71d75ceb Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_videocam_off_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_videocam_white_24dp.png b/src/main/res/drawable-mdpi/ic_videocam_white_24dp.png new file mode 100644 index 000000000..d146209a5 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_videocam_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png new file mode 100644 index 000000000..a629cfff5 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_volume_off_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png b/src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png new file mode 100644 index 000000000..4ed27786a Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_volume_up_black_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_warning_white_48dp.png b/src/main/res/drawable-mdpi/ic_warning_white_48dp.png new file mode 100644 index 000000000..a43fa3c27 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_warning_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_bluetooth_audio_black_24dp.png b/src/main/res/drawable-xhdpi/ic_bluetooth_audio_black_24dp.png new file mode 100644 index 000000000..c1d6b931d Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_bluetooth_audio_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_black_24dp.png b/src/main/res/drawable-xhdpi/ic_call_black_24dp.png new file mode 100644 index 000000000..99f28bbec Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_end_white_48dp.png b/src/main/res/drawable-xhdpi/ic_call_end_white_48dp.png new file mode 100644 index 000000000..8801d0ded Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_end_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_made_black_18dp.png b/src/main/res/drawable-xhdpi/ic_call_made_black_18dp.png new file mode 100644 index 000000000..01cceead3 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_made_black_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_made_white_18dp.png b/src/main/res/drawable-xhdpi/ic_call_made_white_18dp.png new file mode 100644 index 000000000..ea6a8ab5f Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_made_white_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_missed_black_18dp.png b/src/main/res/drawable-xhdpi/ic_call_missed_black_18dp.png new file mode 100644 index 000000000..930fa4373 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_missed_black_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_missed_outgoing_black_18dp.png b/src/main/res/drawable-xhdpi/ic_call_missed_outgoing_black_18dp.png new file mode 100644 index 000000000..e222c17fd Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_missed_outgoing_black_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_missed_outgoing_white_18dp.png b/src/main/res/drawable-xhdpi/ic_call_missed_outgoing_white_18dp.png new file mode 100644 index 000000000..b4fc9bc8b Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_missed_outgoing_white_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_missed_white_18dp.png b/src/main/res/drawable-xhdpi/ic_call_missed_white_18dp.png new file mode 100644 index 000000000..f188eb9aa Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_missed_white_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_received_black_18dp.png b/src/main/res/drawable-xhdpi/ic_call_received_black_18dp.png new file mode 100644 index 000000000..4008ba956 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_received_black_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_received_white_18dp.png b/src/main/res/drawable-xhdpi/ic_call_received_white_18dp.png new file mode 100644 index 000000000..ca2ae411a Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_received_white_18dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_white_24dp.png b/src/main/res/drawable-xhdpi/ic_call_white_24dp.png new file mode 100644 index 000000000..ef45e933a Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_call_white_48dp.png b/src/main/res/drawable-xhdpi/ic_call_white_48dp.png new file mode 100644 index 000000000..b0e020573 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_call_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_clear_white_48dp.png b/src/main/res/drawable-xhdpi/ic_clear_white_48dp.png new file mode 100644 index 000000000..396419219 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_clear_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_headset_black_24dp.png b/src/main/res/drawable-xhdpi/ic_headset_black_24dp.png new file mode 100644 index 000000000..f2664dcde Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_headset_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mic_black_24dp.png b/src/main/res/drawable-xhdpi/ic_mic_black_24dp.png new file mode 100644 index 000000000..cac51c37a Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mic_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png new file mode 100644 index 000000000..fa741be1c Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_mic_off_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_replay_white_48dp.png b/src/main/res/drawable-xhdpi/ic_replay_white_48dp.png new file mode 100644 index 000000000..1573fb111 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_replay_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_videocam_black_24dp.png b/src/main/res/drawable-xhdpi/ic_videocam_black_24dp.png new file mode 100644 index 000000000..b0a3b44d1 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_videocam_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_videocam_off_black_24dp.png b/src/main/res/drawable-xhdpi/ic_videocam_off_black_24dp.png new file mode 100644 index 000000000..2ffb7c5d5 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_videocam_off_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_videocam_white_24dp.png b/src/main/res/drawable-xhdpi/ic_videocam_white_24dp.png new file mode 100644 index 000000000..1b2583d34 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_videocam_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-xhdpi/ic_volume_off_black_24dp.png new file mode 100644 index 000000000..52c9cc1de Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_volume_off_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_volume_up_black_24dp.png b/src/main/res/drawable-xhdpi/ic_volume_up_black_24dp.png new file mode 100644 index 000000000..a0bd18971 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_volume_up_black_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_warning_white_48dp.png b/src/main/res/drawable-xhdpi/ic_warning_white_48dp.png new file mode 100644 index 000000000..8683a2ea9 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_warning_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_bluetooth_audio_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_bluetooth_audio_black_24dp.png new file mode 100644 index 000000000..5fff1521b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_bluetooth_audio_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_call_black_24dp.png new file mode 100644 index 000000000..7c9d1b09c Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_end_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_call_end_white_48dp.png new file mode 100644 index 000000000..c8099a1a1 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_end_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_made_black_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_made_black_18dp.png new file mode 100644 index 000000000..cf2b55b17 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_made_black_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_made_white_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_made_white_18dp.png new file mode 100644 index 000000000..41a58b948 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_made_white_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_missed_black_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_missed_black_18dp.png new file mode 100644 index 000000000..2326b5fba Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_missed_black_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_black_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_black_18dp.png new file mode 100644 index 000000000..f7f8e0746 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_black_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_white_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_white_18dp.png new file mode 100644 index 000000000..a34511a03 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_missed_outgoing_white_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_missed_white_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_missed_white_18dp.png new file mode 100644 index 000000000..a615f59d5 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_missed_white_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_received_black_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_received_black_18dp.png new file mode 100644 index 000000000..75b98ec1c Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_received_black_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_received_white_18dp.png b/src/main/res/drawable-xxhdpi/ic_call_received_white_18dp.png new file mode 100644 index 000000000..13d588caf Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_received_white_18dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_call_white_24dp.png new file mode 100644 index 000000000..90ead2e45 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_call_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_call_white_48dp.png new file mode 100644 index 000000000..a8e295a42 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_call_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_clear_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_clear_white_48dp.png new file mode 100644 index 000000000..4927bc242 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_clear_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_headset_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_headset_black_24dp.png new file mode 100644 index 000000000..baf3ee295 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_headset_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mic_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_mic_black_24dp.png new file mode 100644 index 000000000..ad8299e77 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mic_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_mic_off_black_24dp.png new file mode 100644 index 000000000..084bf3c9f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_mic_off_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_replay_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_replay_white_48dp.png new file mode 100644 index 000000000..5105c2251 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_replay_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_videocam_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_videocam_black_24dp.png new file mode 100644 index 000000000..0039e804e Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_videocam_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_videocam_off_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_videocam_off_black_24dp.png new file mode 100644 index 000000000..32d61d8df Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_videocam_off_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_videocam_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_videocam_white_24dp.png new file mode 100644 index 000000000..44c28e2f2 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_videocam_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png new file mode 100644 index 000000000..2d57c8674 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_volume_off_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_volume_up_black_24dp.png b/src/main/res/drawable-xxhdpi/ic_volume_up_black_24dp.png new file mode 100644 index 000000000..c1e9affba Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_volume_up_black_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_warning_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_warning_white_48dp.png new file mode 100644 index 000000000..88c22324e Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_warning_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_bluetooth_audio_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_bluetooth_audio_black_24dp.png new file mode 100644 index 000000000..2fc2be2ae Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_bluetooth_audio_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_call_black_24dp.png new file mode 100644 index 000000000..61b0e7043 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_end_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_call_end_white_48dp.png new file mode 100644 index 000000000..a09ec6862 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_end_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_made_black_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_made_black_18dp.png new file mode 100644 index 000000000..c7d00c654 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_made_black_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_made_white_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_made_white_18dp.png new file mode 100644 index 000000000..ae471c9fc Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_made_white_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_missed_black_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_missed_black_18dp.png new file mode 100644 index 000000000..b689b79cb Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_missed_black_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_black_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_black_18dp.png new file mode 100644 index 000000000..511793459 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_black_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_white_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_white_18dp.png new file mode 100644 index 000000000..232460ca1 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_missed_outgoing_white_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_missed_white_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_missed_white_18dp.png new file mode 100644 index 000000000..2374dc5a1 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_missed_white_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_received_black_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_received_black_18dp.png new file mode 100644 index 000000000..81dc0c367 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_received_black_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_received_white_18dp.png b/src/main/res/drawable-xxxhdpi/ic_call_received_white_18dp.png new file mode 100644 index 000000000..58421114f Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_received_white_18dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_call_white_24dp.png new file mode 100644 index 000000000..b0e020573 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_call_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_call_white_48dp.png new file mode 100644 index 000000000..0683f06d1 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_call_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_clear_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_clear_white_48dp.png new file mode 100644 index 000000000..bb21ce03a Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_clear_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_headset_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_headset_black_24dp.png new file mode 100644 index 000000000..974457ee1 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_headset_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png new file mode 100644 index 000000000..cf70b63be Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_mic_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_mic_off_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_mic_off_black_24dp.png new file mode 100644 index 000000000..90d0606a4 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_mic_off_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_replay_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_replay_white_48dp.png new file mode 100644 index 000000000..04cbde9af Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_replay_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_videocam_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_videocam_black_24dp.png new file mode 100644 index 000000000..be3ba821a Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_videocam_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_videocam_off_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_videocam_off_black_24dp.png new file mode 100644 index 000000000..2a86702f8 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_videocam_off_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_videocam_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_videocam_white_24dp.png new file mode 100644 index 000000000..ed20c0706 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_videocam_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png new file mode 100644 index 000000000..6d9b35584 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_volume_off_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_volume_up_black_24dp.png b/src/main/res/drawable-xxxhdpi/ic_volume_up_black_24dp.png new file mode 100644 index 000000000..6d09e7f4c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_volume_up_black_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_warning_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_warning_white_48dp.png new file mode 100644 index 000000000..23e6d932d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_warning_white_48dp.png differ diff --git a/src/main/res/drawable/ic_call_black54_24dp.xml b/src/main/res/drawable/ic_call_black54_24dp.xml new file mode 100644 index 000000000..beb641078 --- /dev/null +++ b/src/main/res/drawable/ic_call_black54_24dp.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/src/main/res/drawable/ic_call_white70_24dp.xml b/src/main/res/drawable/ic_call_white70_24dp.xml new file mode 100644 index 000000000..f1a2e46a1 --- /dev/null +++ b/src/main/res/drawable/ic_call_white70_24dp.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/src/main/res/drawable/ic_videocam_black54_24dp.xml b/src/main/res/drawable/ic_videocam_black54_24dp.xml new file mode 100644 index 000000000..5fe6bfea7 --- /dev/null +++ b/src/main/res/drawable/ic_videocam_black54_24dp.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/src/main/res/drawable/ic_videocam_white70_24dp.xml b/src/main/res/drawable/ic_videocam_white70_24dp.xml new file mode 100644 index 000000000..83e61f012 --- /dev/null +++ b/src/main/res/drawable/ic_videocam_white70_24dp.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/src/main/res/layout/activity_edit_account.xml b/src/main/res/layout/activity_edit_account.xml index 4b7a03e8a..8741740ad 100644 --- a/src/main/res/layout/activity_edit_account.xml +++ b/src/main/res/layout/activity_edit_account.xml @@ -338,6 +338,28 @@ tools:ignore="RtlHardcoded"/> + + + + + + + diff --git a/src/main/res/layout/activity_rtp_session.xml b/src/main/res/layout/activity_rtp_session.xml new file mode 100644 index 000000000..2321c6f1b --- /dev/null +++ b/src/main/res/layout/activity_rtp_session.xml @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/layout/message_date_bubble.xml b/src/main/res/layout/message_date_bubble.xml index 37d43bd3b..5e5cd0c4d 100644 --- a/src/main/res/layout/message_date_bubble.xml +++ b/src/main/res/layout/message_date_bubble.xml @@ -1,24 +1,27 @@ + android:paddingBottom="5dp"> + android:layout_centerHorizontal="true" + android:background="@drawable/date_bubble_white"> + + tools:text="Yesterday" /> \ No newline at end of file diff --git a/src/main/res/layout/message_rtp_session.xml b/src/main/res/layout/message_rtp_session.xml new file mode 100644 index 000000000..ad7f06d6a --- /dev/null +++ b/src/main/res/layout/message_rtp_session.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/menu/fragment_conversation.xml b/src/main/res/menu/fragment_conversation.xml index 0725a2fd2..b01e8cfa5 100644 --- a/src/main/res/menu/fragment_conversation.xml +++ b/src/main/res/menu/fragment_conversation.xml @@ -1,23 +1,23 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + android:title="@string/action_secure" + app:showAsAction="always"> - + + android:title="@string/encryption_choice_unencrypted" /> + android:title="@string/encryption_choice_omemo" /> + android:title="@string/encryption_choice_pgp" /> @@ -25,76 +25,93 @@ android:id="@+id/action_attach_file" android:icon="?attr/icon_new_attachment" android:orderInCategory="30" - app:showAsAction="always" - android:title="@string/attach_file"> + android:title="@string/attach_file" + app:showAsAction="always"> + android:icon="?attr/ic_attach_document" + android:title="@string/choose_file" /> + android:icon="?attr/ic_attach_photo" + android:title="@string/attach_choose_picture" /> + android:icon="?attr/ic_attach_camera" + android:title="@string/attach_take_picture" /> + android:icon="?attr/ic_attach_videocam" + android:title="@string/attach_record_video" /> + android:icon="?attr/ic_attach_record" + android:title="@string/attach_record_voice" /> + android:icon="?attr/ic_attach_location" + android:title="@string/send_location" /> + + + + + + + android:title="@string/action_contact_details" + app:showAsAction="never" /> + android:title="@string/action_muc_details" + app:showAsAction="never" /> + android:title="@string/invite_contact" + app:showAsAction="never" /> + android:title="@string/action_clear_history" + app:showAsAction="never" /> + android:title="@string/action_end_conversation" + app:showAsAction="never" /> + android:title="@string/disable_notifications" + app:showAsAction="never" /> + android:title="@string/enable_notifications" + app:showAsAction="never" /> \ No newline at end of file diff --git a/src/main/res/values-ar/strings.xml b/src/main/res/values-ar/strings.xml index 2727dceba..70d491539 100644 --- a/src/main/res/values-ar/strings.xml +++ b/src/main/res/values-ar/strings.xml @@ -111,8 +111,6 @@ إعداد الإهتزاز إهتز عند وصول رسالة جديدة إشعار ضوئي - التنبيه الصوتي - أصدر صوتاً حال ما تصل رسالة جديدة فترة السماح متقدم لا ترسل تقارير أخطاء @@ -582,7 +580,6 @@ ضغط الفيديو جهة الاتصال محجوبة. الإشعارات من طرف غرباء - القيام بإخطاري عندما أتلقى رسائل مِن طرف غُرباء. لقد تلقيت رسالة من شخص غريب حظر الغريب حظر إسم النطاق كاملا @@ -650,7 +647,6 @@ مشاكل إتّصال رسائل رسائل - إعدادات الإشعار الأهمية ، الصوت ، الإهتزاز ضغط الفيديو اعرض الوسائط diff --git a/src/main/res/values-bg/strings.xml b/src/main/res/values-bg/strings.xml index ac241ffd9..7fb29f150 100644 --- a/src/main/res/values-bg/strings.xml +++ b/src/main/res/values-bg/strings.xml @@ -112,8 +112,6 @@ Вибриране при получаване на ново съобщение Известие чрез светодиода Мигане на индикаторния светодиод при получаване на ново съобщение - Тон на звънене - Изпълнение на звук при получаване на ново съобщение Период на пренебрегване Разширени Никога да не се изпращат доклади за сривове @@ -170,8 +168,10 @@ Сигурни ли сте? Ако изтриете профила си, ще загубите цялата история на разговорите си Запис на глас + XMPP адрес username@example.com Парола + Това не е валиден XMPP адрес Няма достатъчно памет. Изображението е твърде голямо. Искате ли да добавите %s към адресния си указател? Инф. за сървъра @@ -343,8 +343,11 @@ Отхвърлен Член Разширен режим + Дай членски привилегии + Премахни членски привилегии Даване на администраторски права Отмяна на администраторските права + Дай права на собственик Премахване от груповия разговор Неуспешна промяна на принадлежността на %s Забраняване на достъпа до груповия разговор @@ -487,7 +490,7 @@ Вашият пълен Jabber идентификатор ще бъде: %s Съгласяване и продължаване Създаване на профил - Използване на собствен сървър + Използване на собствен доставчик Изберете потребителското си име Ръчна промяна на присъствието Задайте присъствието си, когато редактирате съобщението за състоянието си. @@ -600,7 +603,6 @@ Съответстващите разговори са затворени. Контактът е блокиран. Известия от непознати - Известяване за съобщения от непознати. Получено е съобщение от непознат Блокиране на непознатия Блокиране на целия домейн @@ -700,7 +702,6 @@ Съобщения Тихи съобщения Тази категория известия се използва за показване на известия, които не бива да изпълняват звук. Това може да се използва, например, докато използвате друго устройство (по време на Период на пренебрегване). - Настройки за известията Важност, звук, вибрация Компресия на видеото Преглед на медийното съдържание @@ -773,4 +774,6 @@ Не използвайте възможността за възстановяване от резервно копие, за да клонирате (да изпълнявате едновременно) инсталацията. Възстановяването от резервно копие е предназначено за мигриране или в случай, че сте загубили устройството си. Не може да се направи възстановяване от резервно копие. Резервното копие не може да бъде дешифрирано. Правилна ли е паролата? + Присъединихте се към съществуващ канал + Добави съществуващ профил diff --git a/src/main/res/values-ca/strings.xml b/src/main/res/values-ca/strings.xml index 42dfa6499..399b71d48 100644 --- a/src/main/res/values-ca/strings.xml +++ b/src/main/res/values-ca/strings.xml @@ -112,8 +112,6 @@ Vibra quan arribi un missatge nou Notificació LED Fes que la notificació lumínica parpellegi quan arribi un missatge nou - So d\'avís - Reprodueix un so quan arribi un missatge nou Període de gràcia Avançat Mai enviïs informes d\'errors @@ -611,7 +609,6 @@ d\'altres proveïdors donant-los la vostra ID Jabber completa. S\'han tancat les converses corresponents. Contacte bloquejat. Notificacions d\'estranys - Notifica per als missatges rebuts d\'estrangers. S\'ha rebut un missatge de un desconegut Bloqueja al desconegut Bloqueja tot el domini diff --git a/src/main/res/values-cs/strings.xml b/src/main/res/values-cs/strings.xml index 55230764c..7d8026c57 100644 --- a/src/main/res/values-cs/strings.xml +++ b/src/main/res/values-cs/strings.xml @@ -3,6 +3,8 @@ Nastavení Nová konverzace Nastavení účtů + Nastavení účtu + Zavřít tuto konverzaci Detaily kontaktu Zabezpečená konverzace Přidat účet @@ -40,6 +42,7 @@ Registrovat nový účet na serveru Změnit heslo na serveru Sdílet s... + Pozvat kontakt Kontakty Zrušit Nastavit @@ -95,13 +98,12 @@ Vibrovat při přijetí nové zprávy LED upozornění Blikat při přijetí nové zprávy - Tón upozornění - Přehrát zvuk při přijetí nové zprávy Časová lhůta Rozšířené Neodesílat detaily o pádu aplikace Zasláním detailů o důvodu selhání pomůžete dalšímu vývoji aplikace Konverzace Potvrzovat zprávy + Nechat kontaky vědět kdy jste dostali a přečetli jejich zprávy UI Přijmout Došlo k chybě @@ -144,6 +146,7 @@ Povolit účet Jste si jisti? Nahrát hlas + Adresa XMPP jmeno@server.cz Heslo Nedostatek paměti. Obrázek je příliš velký @@ -180,6 +183,7 @@ Získávání klíčů... Hotovo Dešifrovat + Záložky Hledat Vložit kontakt Zobrazit detaily kontaktu @@ -189,6 +193,8 @@ Vybrat Kontakt již existuje Vstoupit + kanál@konference.server.cz/jméno + kanál@konference.server.cz Uložit jako záložku Smazat záložku Odejít @@ -239,12 +245,15 @@ Kopírovat originální URL Poslat znovu URL souboru + Skenovat 2D kód + Zobrazit 2D kód Zobrazit seznam blokovaných Detaily účtu Potvrdit Zkusit znovu Ponechat službu v popředí Zamezit operačnímu systému v ukončení připojení + Vytvořit zálohu Vybrat soubor Přijímám %1$s (%2$d%% dokončeno) Stáhnout %s @@ -309,6 +318,7 @@ %s píše... %s přestal(a) psát Upozornění při psaní + Nechat kontaky vědět když jim píšete zprávu Poslat pozici Zobrazit pozici Nebyla nalezena aplikace pro zobrazení pozice @@ -333,6 +343,8 @@ Žádná Naposledy použitá Vybrat rychlou akci + Prohledat kontakty + Prohledat záložky Poslat soukromou zprávu Uživatelské jméno Uživatelské jméno @@ -457,4 +469,20 @@ Tento přístroj byl ověřen Kopírovat identifikátor Všechny OMEMO klíče byly ověřeny + Sdílet jako čárový kód + Sdílet jako XMPP URI + Sdílet jako HTTP odkaz + Neplatný 2D kód + Upravit stavovou zprávu + Upravit stavovou zprávu + Prohledat zprávy + Jméno skupinového chatu + Vaše jméno + Vytvořit skupinový chat + Vytvořit soukromý skupinový chat + Vytvořit veřejný kanál + Jméno kanálu + Najít kanály + Možné porušení soukromí + search.jabber.network.

Používání této služby odešle vaši IP adresu a vyhledávaný termín této službě. Pro více informací konzultujte jejich Zásady ochrany osobních údajů.]]>
diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml index a6173252d..d0139ff05 100644 --- a/src/main/res/values-de/strings.xml +++ b/src/main/res/values-de/strings.xml @@ -116,8 +116,10 @@ Vibrieren bei Erhalt einer neuen Nachricht LED Benachrichtigung Blinke bei Erhalt einer neuen Nachricht - Benachrichtigungston - Benachrichtigungston wiedergeben + Klingelton + Benachrichtigungston + Benachrichtigungston für neue Nachrichten + Klingelton für eingehende Anrufe Schonfrist Die Zeitspanne, in der Benachrichtigungen nach der Erkennung von Aktivitäten auf einem deiner anderen Geräte unterdrückt werden. Erweitert @@ -190,6 +192,7 @@ XEP-0191: Blocking Command XEP-0237: Roster Versioning XEP-0198: Stream Management + XEP-0215: External Service Discovery XEP-0163: PEP (Avatare/OMEMO) XEP-0363: HTTP File Upload XEP-0357: Push @@ -645,7 +648,7 @@ Zugehörige Unterhaltung beendet. Kontakt gesperrt. Benachrichtigungen von Unbekannten - Benachrichtigen bei Erhalt einer neuen Nachricht von Unbekannten. + Benachrichtigen bei Erhalt von Nachrichten und Anrufen von Unbekannten. Erhaltene Nachricht von einem Unbekannten Unbekannten sperren Gesamte Domain sperren @@ -744,10 +747,14 @@ Verbindungsprobleme Diese Benachrichtigungsart wird verwendet, um eine Benachrichtigung anzuzeigen, falls es ein Problem bei der Verbindung zu einem Konto gibt. Nachrichten + Anrufe Nachrichten + Eingehende Anrufe + Laufende Anrufe Lautlose Nachrichten Diese Benachrichtigungsart wird verwendet, um Benachrichtigungen anzuzeigen, die keinen Ton auslösen sollen. Zum Beispiel, wenn du auf einem anderen Gerät aktiv bist (Schonfrist). - Benachrichtigungseinstellungen + Benachrichtigungseinstellungen + Anrufeinstellungen Wichtigkeit, Klang, Vibrationen Video komprimieren Medien anzeigen @@ -846,7 +853,7 @@ Alle können andere einladen. XMPP-Adressen sind für Administratoren sichtbar. XMPP-Adressen sind für alle sichtbar. - Dieser öffentliche Channel hat keine Teilnehmer. Lade deine Kontakte ein oder benutzt die \"Teilen\"-Schaltfläche, um die XMPP-Adresse zu verteilen. + Dieser öffentliche Channel hat keine Teilnehmer. Lade deine Kontakte ein oder benutze die \"Teilen\"-Schaltfläche, um die XMPP-Adresse zu verteilen. Dieser private Gruppenchat hat keine Teilnehmer. Rechte verwalten Teilnehmer suchen @@ -879,6 +886,35 @@ Channelsuchmethode Sicherungskopie Über + Bitte aktiviere ein Konto + Anrufen + Eingehender Anruf + Eingehender Videoanruf + Verbinden + Verbunden + Anruf annehmen + Anruf beenden + Annehmen + Ablehnen + Geräte lokalisieren + Klingelt + Besetzt + Anruf kann nicht verbunden werden + Rückrufruf + Anwendungsfehler + Auflegen + Laufender Anruf + Laufender Videoanruf + Deaktiviere Tor, um Anrufe zu tätigen + Eingehender Anruf + Eingehender Anruf · %s + Ausgehender Anruf + Ausgehender Anruf · %s + Entgangener Anruf + Audioanruf + Videoanruf + Dein Mikrofon ist nicht verfügbar + Du kannst immer nur einen Anruf zur gleichen Zeit machen. %1$d Teilnehmer anzeigen %1$d Teilnehmer anzeigen diff --git a/src/main/res/values-el/strings.xml b/src/main/res/values-el/strings.xml index eb99b3141..bd7bfdf0f 100644 --- a/src/main/res/values-el/strings.xml +++ b/src/main/res/values-el/strings.xml @@ -30,6 +30,7 @@ μόλις τώρα πριν από 1 λεπτό πριν από %d λεπτά + %d αδιάβαστες συζητήσεις αποστολή... Αποκρυπτογράφηση μηνύματος. Παρακαλώ περιμένετε... OpenPGP κρυπτογραφημένο μήνυμα @@ -115,9 +116,8 @@ Δόνηση όταν δέχεστε νέο μήνυμα Ειδοποίηση LED Ειδοποίηση μέσω αναβοσβήματος όταν δέχεστε νέο μήνυμα - Ήχος - Κουδούνισμα όταν δέχεστε νέο μήνυμα Περίοδος Χάριτος + Ο χρόνος σίγασης ειδοποιήσεων αφότου ανιχνευθεί δραστηριότητα σε μια από τις άλλες συσκευές σας. Προχωρημένος Να μην αποστέλλονται αναφορές λαθών Στέλνοντας ίχνη στοίβας βοηθάτε την συνεχόμενη ανάπτυξη του Conversations @@ -153,6 +153,7 @@ Το όνομα χρησιμοποιείται ήδη Ολοκλήρωση εγγραφής Ο διακομιστής δεν υποστηρίζει εγγραφή + Άκυρο κουπόνι εγγραφής TLS αποτυχία επικοινωνίας Πολιτική παραβίασης Μη συμβατός διακομιστής @@ -331,6 +332,7 @@ το %s προσφέρθηκε για μεταφόρτωση Ακύρωση μετάδοσης η μετάδοση του αρχείου απέτυχε + η μεταφορά αρχείου ακυρώθηκε Το αρχείο έχει διαγραφεί Δεν βρέθηκε εφαρμογή για να ανοίξει το αρχείο Δεν βρέθηκε εφαρμογή για να ανοίξει τον σύνδεσμο @@ -506,6 +508,7 @@ Παύση ειδοποιήσεων Συμπίεση εικόνας Πάντα + Μεγάλες εικόνες μόνο Ενεργοποίηση βελτιστοποίησης χρήσης μπαταρίας Η συσκευή σας χρησιμοποιεί βελτιστοποίηση στην χρήση μπαταρίας του Conversations που μπορεί να οδηγήσει σε αργοπορημένες ειδοποιήσεις ή ακόμα και σε απώλεια μηνυμάτων.\nΠροτείνεται να την απενεργοποιήσετε Η συσκευή σας χρησιμοποιεί βελτιστοποίηση στην χρήση μπαταρίας του Conversations που μπορεί να οδηγήσει σε αργοπορημένες ειδοποιήσεις ή ακόμα και σε απώλεια μηνυμάτων.\nΘα σας ζητηθεί να την απενεργοποιήσετε. @@ -551,6 +554,7 @@ Ιδιωτικότητα Θέμα Επιλογή παλέτας χρωμάτων + Αυτόματο Ανοιχτόχρωμο θέμα Σκουρόχρωμο θέμα Αδυναμία σύνδεσης στο OpenKeychain @@ -638,7 +642,6 @@ Οι αντίστοιχες συζητήσεις έκλεισαν. Η επαφή αποκλείστηκε. Ειδοποιήσεις από άγνωστους - Ειδοποίηση για μηνύματα που έρχονται από άγνωστους Λήψη μηνύματος από άγνωστο Αποκλεισμός αγνώστου Αποκλεισμός ολόκληρου τομέα @@ -740,7 +743,6 @@ Μηνύματα Σιωπηρά μηνύματα Αυτή η κατηγορία ειδοποιήσεων χρησιμοποιείται για να εμφανίσει ειδοποιήσεις που δεν θα έπρεπε να παράγουν ήχο. Για παράδειγμα όταν κάποιος είναι ενεργός σε άλλη συσκευή (περίοδος χάριτος). - Ρυθμίσεις ειδοποίησης Σημασία, Ήχος, Δόνηση Συμπίεση βίντεο Εμφάνιση μέσου @@ -862,4 +864,16 @@ Αυτός ο λογαριασμός έχει προστεθεί ήδη Παρακαλώ εισάγετε τον κωδικό για αυτό το λογαριασμό Αδυναμία εκτέλεσης αυτής της λειτουργίας + Είσοδος σε δημόσιο κανάλι... + Η εφαρμογή από την οποία έγινε διαμοίραση δεν έδωσε δικαιώματα πρόσβασης στο αρχείο. + + Τοπικός διακομιστής + Οι περισσότεροι χρήστες πρέπει να επιλέξουν ‘jabber.network’ για καλύτερες προτάσεις από το σύνολο του οικοσυστήματος XMPP. + Μέθοδος ανακάλυψης καναλιού + Αντίγραφο ασφαλείας + Σχετικά με + + Εμφάνιση %1$d συμμετέχοντα + Εμφάνιση %1$d συμμετεχόντων + diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml index 463103220..5dc3c0c20 100644 --- a/src/main/res/values-es/strings.xml +++ b/src/main/res/values-es/strings.xml @@ -116,8 +116,10 @@ Vibra cuando llega un nuevo mensaje Luz La luz parpadea cuando llega un nuevo mensaje - Tono - Reproduce tono con la notificación + Tono de llamada + Sonido de notificación + Sonido de notificación para nuevos mensajes + Tono para las nuevas llamadas Periodo de gracia El periodo de tiempo en el que las notificaciones están silenciadas tras detectar actividad en otro de tus dispositivos. Avanzado @@ -190,6 +192,7 @@ XEP-0191: Blocking Command XEP-0237: Roster Versioning XEP-0198: Stream Management + XEP-0215: External Service Discovery XEP-0163: PEP (Avatars / OMEMO) XEP-0363: HTTP File Upload XEP-0357: Push @@ -645,7 +648,7 @@ Conversación correspondiente cerrada. Contacto bloqueado. Notificaciones de desconocidos - Notificar mensajes recibidos de contactos desconocidos. + Notificar de nuevos mensajes y llamadas recibidas de contactos desconocidos. Mensaje recibido de un contacto desconocido Bloquear desconocido Bloquear el dominio completo @@ -744,10 +747,14 @@ Problemas de conectividad Esta categoría de notificación se usa para mostrar una notificación en caso de que exista un problema conectándose a una cuenta. Mensajes + Llamadas Mensajes + Llamadas entrantes + Llamadas salientes Mensajes sin sonido Este grupo de notificaciones se usa para mostrar notificaciones que no deberían emitir ningún sonido. Por ejemplo, cuando estás activo en otro dispositivo (periodo de gracia). - Ajustes de Notificación + Ajustes de notificación de mensajes + Ajustes de notificación de llamadas Importancia, Sonido, Vibración Compresión de video Ver galería @@ -879,6 +886,35 @@ Método para la búsqueda de Canales Copia de respaldo Acerca de + Por favor, habilita una cuenta + Hacer una llamada + Llamada entrante + Videollamada entrante + Conectando + Conectado + Aceptar llamada + Terminar llamada + Contestar + Descartar + Localizando dispositivos + Llamando + Ocupado + No se ha podido realizar la llamada + Llamada rechazada + Fallo en la aplicación + Colgar + Llamada saliente + Video llamada saliente + Deshabilitar Tor para hacer llamadas + Llamada entrante + Llamada entrante · %s + Llamada saliente + Video llamada saliente · %s + Llamada perdida + Audio llamada + Video llamada + Tu micrófono no está disponible + Solo puedes hacer una llamada a la vez Ver %1$d Participante Ver %1$d Participantes diff --git a/src/main/res/values-eu/strings.xml b/src/main/res/values-eu/strings.xml index 1f6aa6a6e..a561b3a78 100644 --- a/src/main/res/values-eu/strings.xml +++ b/src/main/res/values-eu/strings.xml @@ -116,8 +116,6 @@ Mezu berri bat heltzerakoan dardartu LED jakinarazpena Mezu berri bat heltzerakoan jakinarazpenen argia keinu egin - Dei-tonua - Mezu berri bat heltzerakoan dei-tonua jo Grazia epea Aurreratua Gelditze txostenik ez bidali inoiz @@ -506,6 +504,7 @@ Jakinarazpenak gelditu dira Irudiak konprimatu Beti + Irudi handiak soilik Bateriaren optimizazioak gaituta Zure gailua jakinarazpen atzeratuak edota mezuen galera ekar lezaketen bateriaren optimizazio handiak egiten ari da Conversationsen.\nHoriek ezgaitzea gomendatzen da. Zure gailua jakinarazpen atzeratuak edota mezuen galera ekar lezaketen bateriaren optimizazio handiak egiten ari da Conversationsen.\nJarraian hauek ezgaitzea eskatuko zaizu. @@ -550,6 +549,7 @@ Pribatutasuna Gaia Kolore paleta hautatu + Automatikoa Gai argia Gai iluna Ezin izan da OpenKeychainekin konektatu @@ -637,7 +637,6 @@ Dagokion elkarrizketa itxi egin da. Kontaktua blokeatu da. Ezezagunen jakinarazpenak - Ezezagunen mezuak jasotzerakoan jakinarazi. Ezezagun baten mezu bat jaso duzu Ezezaguna blokeatu Domeinu osoa blokeatu @@ -739,7 +738,6 @@ Mezuak Mezu isilak Jakinarazpen talde hau inolako soinurik egin beharko ez luketen jakinarazpenak erakusteko erabiltze da. Adibidez beste gailu batean aktibo zaudenean (grazia epea). - Jakinarazpenen ezarpenak Garrantzia, soinua, dardara Bideoen konprimatzea Ikusi multimedia @@ -863,4 +861,15 @@ Mesedez idatzi ezazu kontu honetarako pasahitza Ezin izan da ekintza hau burutu Kanal publiko batean sartu… + Partekatzen duen aplikazioak ez du baimenik eman fitxategi honetara sartzeko. + jabber.network + Zerbitzari lokala + Kanalak aurkitzeko modua + Babeskopia + Honi buruz + Mesedez kontu bat gaitu + + Parte-hartzaile %1$d ikusi + %1$d parte-hartzaile ikusi + diff --git a/src/main/res/values-fa-rIR/strings.xml b/src/main/res/values-fa-rIR/strings.xml index d70439f87..3cc805356 100644 --- a/src/main/res/values-fa-rIR/strings.xml +++ b/src/main/res/values-fa-rIR/strings.xml @@ -94,8 +94,6 @@ هنگام دریافت پیام جدید بلرز اعلان از طریق LED چشمک زدن چراغ اعلان هنگام رسیدن پیام جدید - آهنگ زنگ - هنگام دریافت پیام جدید صدا پخش کن مهلت پیشرفته هیچ وقت گزارش خرابی را ارسال نکن diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml index 54459d2e4..f11291e64 100644 --- a/src/main/res/values-fr/strings.xml +++ b/src/main/res/values-fr/strings.xml @@ -116,8 +116,6 @@ Vibrer lors de la réception d\'un message Notification LED Faire clignoter la LED lors de la réception d\'un message - Sonnerie - Jouer un son lors de la réception d\'un message Période sans notification La durée pendant laquelle les notifications sont désactivées après la détection d\'une activité sur l\'un de vos autres appareils. Avancé @@ -155,6 +153,7 @@ Identifiant déjà utilisé Enregistrement réussi Le serveur ne permet pas l\'enregistrement + Jeton d’inscription invalide La négociation TLS a echoué Violation de politique Serveur incompatible @@ -291,6 +290,7 @@ La discussion de groupe a été interrompue Vous n\'appartenez plus à ce groupe de discussion avec le compte %s + hébergé sur %s Vérification de %s sur l\'hôte HTTP Vous n\'êtes pas connecté. Essayez plus tard. Vérification de la taille de %s @@ -507,7 +507,9 @@ Notifications désactivées Notifications en pause Compression de l\'image + Remarque : Utiliser « Choisir un fichier » au lieu de « Choisir une image » pour envoyer des images individuelles non compressées sans tenir compte de ce paramètre. Toujours + Grandes images seulement Optimisations de batterie activées Votre appareil applique sur Conversations des optimisations de batterie très strictes qui pourraient provoquer des retards dans les notifications, voire des pertes de messages.\nNous vous recommandons de les désactiver. Votre appareil applique sur Conversations des optimisations de batterie très strictes qui pourraient provoquer des retards dans les notifications, voire des pertes de messages.\nVous allez maintenant avoir la possibilité de les désactiver. @@ -553,6 +555,7 @@ Confidentialité Thème Choisir la palette de couleurs + Automatique Thème Clair Thème Sombre Connection à OpenKeychain impossible @@ -640,7 +643,6 @@ Conversations correspondantes fermées. Contact bloqué. Notifications d\'inconnus - Notifier pour les messages envoyés par des personnes inconnues Message d\'un inconnu reçu Bloquer l\'inconnu Bloquer le domaine entier @@ -742,7 +744,6 @@ Messages Messages silencieux Ce groupe de notifications est utilisé pour afficher les notifications qui ne doivent pas émettre de son. Par exemple, lorsque le son est activé sur un autre appareil (délai de grâce). - Options de notification Importance, son, vibration Compression vidéo Voir les média @@ -867,4 +868,15 @@ Action impossible à réaliser Rejoindre le canal public ... L\'application de partage n\'a pas accordé la permission d\'accéder à ce fichier. + + jabber.network + Serveur local + La plupart des utilisateurs devraient choisir « jabber.network » pour de meilleures suggestions provenant de l’écosystème public entier de XMPP. + Méthode de découverte des canaux + Sauvegarde + À propos + + Voir %1$d participant + Voir %1$d participants + diff --git a/src/main/res/values-gl/strings.xml b/src/main/res/values-gl/strings.xml index f9960ba4a..58ffd4e64 100644 --- a/src/main/res/values-gl/strings.xml +++ b/src/main/res/values-gl/strings.xml @@ -24,7 +24,7 @@ Compartir na conversa Iniciar conversa Escoller Contacto - Escolla contactos + Seleccionar contactos Compartir coa conta Lista de bloqueo agora @@ -72,9 +72,9 @@ Non preguntar de novo Non puido conectarse a conta Erro na conexión a múltiples contas - Pulse aquí para xestionar as súas contas + Preme aquí para xestionar as túas contas Adxuntar - O contacto non está na sua lista. ¿Queres engadilo? + O contacto non está na túa lista. ¿Queres engadilo? Engadir contacto Erro ao enviar Preparando imaxe para enviar @@ -95,7 +95,7 @@ Enviar mensaxe cifrado con OpenPGP Modificouse o teu alcume Enviar sen cifrar - Fallou o descifrado. Quizábeis non teñas a clave privada apropiada. + Fallou o descifrado. Quizais non teñas a chave privada apropiada. OpenKeychain Conversations emprega unha aplicación de terceiros chamada OpenKeychain para cifrar e descifrar mensaxes e xestionar as túas claves públicas.\n\nOpenKeychain está publicado baixo licencia GPLv3 e disponible en F-Droid e Google Play.\n\n(Por favor, reinicie Conversations despois.) Reiniciar @@ -104,9 +104,9 @@ ofrecendo… agardando... Clave OpenPGP non atopada - Conversations non foi quen de cifrar as túas mensaxes porque o teu contactos non está anunciando a súa clave pública.\n\nPor favor, pídelle ao teu contacto que configure OpenPGP. + Conversations non foi quen de cifrar as túas mensaxes porque o teu contacto non está anunciando a súa chave pública.\n\nPor favor, pídelle ao contacto que configure OpenPGP. Non se atoparon chaves OpenPGP - Conversations non pode cifrar a súa mensaxe porque os seus contactos no publicaron a súa chave pública.\n\n Por favor solicite aos seus contactos que configuren OpenPGP. + Conversations non pode cifrar a túa mensaxe porque os teus contactos non publicaron a súa chave pública.\n\n Por favor solicitalle aos teus contactos que configuren OpenPGP. Xeral Aceptar ficheiros De forma automática aceptar ficheiros menores de… @@ -116,15 +116,17 @@ Vibra cando chega unha nova mensaxe Notificación LED Luz pestanexante cando chegue unha nova mensaxe - Ton de aviso - Emitir son cando chegue unha nova mensaxe + Ton de chamada + Son da notificación + Son da notificación para novas mensaxes + Ton de chamada para chamadas entrantes Período de graza O tempo no que as notificacións son silenciadas tras detectar actividade en algún dos teus outros dispositivos. Avanzado Nunca enviar informe de erros - Enviando volcados de pilas axudas ao desenvolvemento de Conversations + Enviando trazas do rexistro axudas ao desenvolvemento de Conversations Confirmación de mensaxes - Permitir aos seus contactos saber si recibeu e leu as súas mensaxes + Permitir aos teus contactos saber se recibiches e liches as súas mensaxes Interface OpenKeychain informou de un fallo. Chave incorrecta para cifrar. @@ -132,9 +134,9 @@ Produciuse un erro Fallo A túa conta - Enviar actualizacións de presencia - Recibir actualizacións de presencia - Solicitar actualizacións de presencia + Enviar actualizacións de presenza + Recibir actualizacións de presenza + Solicitar actualizacións de presenza Seleccionar imaxe Facer foto Por defecto otorgar peticiones de suscripción @@ -170,7 +172,7 @@ Publicar avatar Publicar chave pública OpenPGP Eliminar a chave pública OpenPGP - Está seguro de que quere eliminar a súa chave pública OpenPGP do seu anuncio de presencia? \nOs seus contactos non poderán enviarlle mensaxes cifradas con OpenPGP. + Tes a certeza de que queres eliminar a túa chave pública OpenPGP do anuncio de presenza? \nOs teus contactos non poderán enviarche mensaxes cifradas con OpenPGP. Publicouse a chave pública OpenPGP Habilitar Seguro? @@ -182,7 +184,7 @@ Contrasinal Non é un enderezo XMPP válido Exceso de memoria. A imaxe é demasiado grande - Quere engadir a 1%s a súa libreta de enderezos? + Queres engadir a %s a túa libreta de enderezos? Info do servidor XEP-0313: MAM XEP-0280: Copia de mensaxes @@ -190,6 +192,7 @@ XEP-0191: Bloqueo de ordes XEP-0237: Roster Versioning XEP-0198: Stream Management + XEP-0215: Descubrimento de Servizo Externo XEP-0163: PEP (Avatars / OMEMO) XEP-0363: HTTP File Upload XEP-0357: Push @@ -240,7 +243,7 @@ Asunto Entrando na conversa en grupo Saír - Contacto engadido a súa lista de contactos + Contacto engadido a túa lista de contactos Voltar a engadir %s leeu ate este punto %s leu ate este punto @@ -249,8 +252,8 @@ Publicar Toque no avatar para escoller imaxe desde a galería Publicando... - O servidor rexeitou a súa publicación - Algo fallou mentras se convertía a súa imaxe + O servidor rexeitou a túa publicación + Algo fallou mentras se convertía a túa imaxe Non se puido salvar o avatar no disco (ou pulsación longa para voltar ao valor por omisión) O seu servidor non admite a publicación de avatares @@ -266,15 +269,15 @@ Habilitar A conversa en grupo require contrasinal Introducir contrasinal - Por favor, primeiro solicite actualizacións de presencia ao seu contacto.\n\n Esto utilizarase para determinar qué cliente(s) está a usar o seu contacto. + Por favor, primeiro solicita actualizacións de presenza ao teu contacto.\n\n Esto utilizarase para determinar qué cliente(s) está a usar o teu contacto. Solicitar agora Ignorar - Aviso: Enviando esto sen mutuas actualización de presenza podería causar problemas.\n\n Vaia aos detalles do contacto para verificar as súas suscricións de presenza. + Aviso: Ao enviar esto sen mutuas actualizacións de presenza podería causar problemas.\n\n Vaite aos detalles do contacto para verificar as túas suscricións de presenza. Seguridade Permitir a corrección de mensaxes - Permitir aos seus contactos editar as súas mensaxes de xeito retroactivo - Axustes de experto - Por favor teña coidado con estos axustes + Permitir aos teus contactos editar as súas mensaxes de xeito retroactivo + Axustes de experta + Por favor ten tino con estos axustes Acerca de %s Non molestar Hora de inicio @@ -283,7 +286,7 @@ As notificacións serán silenciadas durante estas horas Outro Sincronizar cos marcadores - Unirse e deixar conversas de grupo de acordo coa marca auto-unirse nos seus marcadores. + Unirse e deixar conversas de grupo de acordo coa marca auto-unirse nos teus marcadores. Copiouse a pegada dixital OMEMO ao portapapeis! Non podes acceder a esta conversa en grupo Esta conversa en grupo é so para membros @@ -296,7 +299,7 @@ Comprobando %s no servidor HTTP Non está conectada. Inténteo máis tarde Comprobando o tamaño de %s - Comporbar o tamaño de %1$s en %2$s + Comprobar o tamaño de %1$s en %2$s Opcións da mensaxe Cita Pegar como cita @@ -314,14 +317,14 @@ Confirmar Inténteo de novo Manter servizo en primeiro plano - Evita que o sistema operativo corte a súa conexión - Crear respaldo - Os ficheiros de respaldo gardaranse en %s - Creando ficheiros de respaldo - Creouse o respaldo - Os ficheiros de respaldo gardáronse en %s - Restaurando o respaldo - O seu respaldo foi restablecido + Evita que o sistema operativo corte a conexión + Crear copia de apoio + Os ficheiros de copia gardaranse en %s + Creando ficheiros de apoio + Creouse o ficheiro + Os ficheiros de apoio gardáronse en %s + Restaurando a copia + O copia foi restablecida Non esqueza activar a conta. Escoller ficheiro Recibindo %1$s (%2$d %% completado) @@ -348,9 +351,9 @@ Copiar pegada OMEMO ao portapapeis Rexenerar a chave OMEMO Limplar dispositivos - Está segura de que quere eliminar todos os outros dispositivos da declaración OMEMO? A próxima vez que se conecten os seus dispositivos estos voltarán a anunciarse pero non rebirán mensaxes enviados mentras tanto. + Tes a certeza de que queres eliminar todos os outros dispositivos da declaración OMEMO? A próxima vez que se conecten os teus dispositivos estos voltarán a anunciarse pero non rebirán mensaxes enviados mentras tanto. Non hai chaves dispoñibles que se poidan usar con este contacto.\nNon se puideron obter novas chaves do servidor. Quizáis hai algún problema coas chaves do seu contacto. - Non hai chaves utilizables dispoñibles para este contacto.\nAsegúrese de que ambos teñen mutua suscrición de presenza. + Non hai chaves utilizables dispoñibles para este contacto.\nAsegúrate de que ambos tedes mutua subscrición de presenza. Algo saíu mal Obtendo historial desde o servidor Non hai máis historial no servidor @@ -380,7 +383,7 @@ Non se puido mudar a afiliación de %s Prohibición da conversa en grupo Prohibir no canal - Está a eliminar %s de un canal público. O único xeito de facer esto é vetar esta usuaria para sempre. + Estás a eliminar %s dun canal público. O único xeito de facer isto é vetar esta usuaria para sempre. Rexeitar agora Non se puido mudar o rol de %s Configuración do grupo privado de conversa @@ -416,7 +419,7 @@ %s están escribindo... %s deixaron de escribir Notificacións de escritura - Permita aos seus contactos que saiban cando lles está a escribir + Permitelle aos teus contactos que saiban cando lles estás a escribir Enviar localización Mostrar localización Non se atopou un aplicativo para mostrar a localización @@ -436,11 +439,11 @@ %d certificado eliminado %d certificados eleminados - Substitúa o botón enviar con unha acción rápida + Cambia o botón de enviar por unha acción rápida Acción rápida Ningunha Utilizadas recentemente - Escolla a acción rápida + Escolle a acción rápida Buscar contactos Buscar marcadores Enviar mensaxe privada @@ -477,7 +480,7 @@ Introduza o texto da imaxe superior A cadea de certificados non é de confianza Os enderezos XMPP non concordan co certificado - Anvoar certificado + Anovar certificado Fallo obtendo a chave OMEMO! Comprobouse a chave OMEMO co certificado! O seu dispositivo non admite a selección de certificados do cliente! @@ -502,7 +505,7 @@ Conversations necesita acceso ao almacenamiento externo Conversations necesita acceso á cámara Sincronice con todos os contactos - Conversations quere confrontar a súa lista de contactos no servidor coa súa libreta de enderezos local para mostrarlle os nomes completos e avatares.\n\nConversations só lerá os seus contactos de xeito local e sen subilos ao servidor.\n\nA continuación pediráselle permiso para acceder aos contactos. + Conversations quere confrontar a túa lista de contactos no servidor coa túa libreta de enderezos local para mostrarche os nomes completos e avatares.\n\nConversations só lerá os teus contactos de xeito local e sen subilos ao servidor.\n\nA continuación pediráseche permiso para acceder aos contactos.
Non gardaremos unha copia de esos números de teléfono.\n\nPara máis información lea a nosa política de intimidade.

A continuación pediráselle permiso para acceder aos contactos.]]>
Notificar todas as mensaxes Notificar só cando é mencionada @@ -528,13 +531,13 @@ Compartir URI con...
Podes rexistrarte co teu número de teléfono e Quicksy suxerillache automáticamente —tomando os números da túa libreta de enderezos como referencia— posibles contactos para ti.

Ao rexistrarte aceptas a nosa política de intimidade.]]>
Aceptar & continuar - Guiarémola a través do proceso de creación de unha conta en chat.sum7.eu.\nAo escoller a chat.sum7.eu como fornecedor poderá comunicar con usuarias de outros fornecedores proporcionándolles o seu enderezo XMPP completo. + Guiarémoste a través do proceso de creación dunha conta en chat.sum7.eu.\nAo escoller a chat.sum7.eu como fornecedor poderás comunicar con usuarias de outros fornecedores proporcionándolles o teu enderezo XMPP completo. O seu enderezo XMPP completo será: %s Crear conta Utilizar o meu propio proveedor - Escolla un identificador + Elixe un identificador Xestionar a disponibilidade manualmente - Configure a súa disponibilidade ao editar a mensaxe de estado. + Configura a túa dispoñibilidade ao editar a mensaxe de estado. Mensaxe de estado Dispoñible para conversar En liña @@ -553,10 +556,10 @@ Medio Longo Emitir a última interacción coa usuaria - Permitirlle aos contactos saber cando está a usar Conversations + Permitirlle aos contactos saber cando estás a utilizar Conversations Intimidade Decorado - Escolla a gama de cores + Escolle a gama de cores Automático Decorado claro Decorado escuro @@ -578,7 +581,7 @@ Non se puido actualizar a conta Informe de este JID como emisor de mensaxes non desexadas. Borrar identidades OMEMO - Rexenerar as súas chaves OMEMO. Todos os seus contactos deberán verificalo de novo. Utilice esto como último recurso. + Rexenerar as túas chaves OMEMO. Todos os teus contactos deberán verificarte de novo. Utiliza esto como último recurso. Eliminar as chaves seleccionadas. Precisa estar conectada para publicar o seu avatar. Mostrar mensaxe do fallo @@ -645,7 +648,7 @@ Conversas correpondentes pechadas. Contacto bloqueado. Notificacións de estraños - Notifica as mensaxes recibidas por parte de estraños. + Notificar as mensaxes e chamadas recibidas por parte de extraños. Mensaxe recibida de un estraño Bloquear estraño Bloquear o dominio ao completo @@ -679,7 +682,7 @@ Detalles do certificado: Unha vez O escaner de código QR necesita acceso á cámara. - Desprazarse ata la parte inferior + Desprazarse ata a parte inferior Desprazarse cara abaixo logo de enviar unha mensaxe Editar a Menxase de Estado Editar a menxase de estado @@ -687,8 +690,8 @@ Conversations non pode enviar mensaxes cifradas a %1$s Isto pode deberse a que o seu contacto utiliza un servidor ou cliente obsoleto que non pode manexar OMEMO. Non se pode recuperar la lista de dispositivos Non se poden recuperar os paquetes de dispositivos - Suxerencia: Nalgúns casos, isto pode solucionarse engadíndose mutuamente as súas listas de contactos. - ¿Está segura de que quere desactivar o cifrado OMEMO para esta conversación? Isto permitirá que o administrador do seu servidor lea as súas mensaxes, pero pode ser a única forma de comunicarse con persoas que usan clientes obsoletos. + Suxestión: Nalgúns casos, isto pode solucionarse engadíndovos mutuamente as vosas listas de contactos. + ¿Tes a certeza de que queres desactivar o cifrado OMEMO para esta conversa? Isto permitirá que o administrador do teu servidor lea as túas mensaxes, pero pode ser a única forma de comunicarse con persoas que usan clientes obsoletos. Desactivar agora Borrador: Cifrado OMEMO @@ -742,12 +745,16 @@ Esta categoría de notificacións utilízase para mostrar unha notificación permanente indicando que Conversations está a funcionar. Información do estado Problemas de conexión - Esta categoría de conexión utilízase para mostrar unha notificación en caso de que houbese un problema ao conectar a conta. + Esta categoría de notificación utilízase para mostrar unha notificación en caso de que houbese un problema ao conectar a conta. Mensaxes + Chamadas Mensaxes + Chamadas recibidas + Chamadas realizadas Mensaxes acalados Este grupo de notificacións é utilizado para mostrar notificacións que non debería activar ningún son. Por exemplo, cando está activo en outro dispositivo (Período de Graza). - Axustes das notificacións + Axustes de notificación das mensaxes + Axustes da notificación de chamadas Importancia, Son, Vibrar Compresión de vídeo Ver medios @@ -762,7 +769,7 @@ Xa está a escribir un borrador. Característica non implementada Código de país non válido - Escolla un país + Indica un país número de teléfono Valide o seu número de teléfono Quicsy enviaralle unha mensaxe SMS (poderíanse aplicar cargos) para validar o seu número de teléfono. Introduza o código de país e número de teléfono. @@ -792,14 +799,14 @@ Non se puido conectar co servidor. Non se puido establecer unha conexión segura. Non se atopou o servidor. - Algo fallou ao xestionar a súa solicitude. - Datos inválidos do usuario + Algo fallou ao xestionar a túa solicitude. + Entrada da usuaria non válida Non dispoñible temporalmente. Inténteo máis tarde. Se conexión a rede. Inténteo de novo en %s Taxa de transferencia limitada Demasiados intentos - Está a utilizar unha versión desactualizada de esta app. + Estás a usar unha versión desactualizada desta app. Actualizar Este número de teléfono está actualmente ligado a outro dispositivo. Por favor, escribe o teu nome para permitir que a xente que non te ten na axenda de enderezos sepa quen es. @@ -816,12 +823,12 @@ Abrir con... Imaxe de perfil en Conversations Escoller conta - Restablecer respaldo + Restablecer copia de apoio Restablecer - Introduza o contrasinal da conta %s para restablecer o respaldo. - Non utilice a función de restaurar o respaldo nun intento de clonar (utilizar simultaneamente) unha instalación. Restaurar un respaldo só ten sentido para migrar ou en caso de perda do dispositivo orixinal. - Non se puido restaurar o respaldo. - Non se puido descifrar o respaldo. É correcto o contrasinal? + Escribe o contrasinal da conta %s para restablecer a copia. + Non utilices a función de restaurar a copia nun intento de clonar (utilizar simultaneamente) unha instalación. Restaurar unha copia só ten sentido para migrar ou en caso de perda do dispositivo orixinal. + Non se puido restaurar a copia. + Non se puido descifrar a copia. É correcto o contrasinal? Respaldar & Restaurar Introducir enderezo XMPP Crear grupo de conversa @@ -846,7 +853,7 @@ Calquera pode convidar a outras. Os enderezos XMPP son visibles para a administración. Os enderezos XMPP son visibles para calquera. - Este canal público non ten participantes. Convide aos seus contactos ou utilice o botón compartir para distribuír o seu enderezo XMPP. + Este canal público non ten participantes. Convida aos teus contactos ou utiliza o botón compartir para distribuír o teu enderezo XMPP. Este grupo privado non ten participantes. Xestionar privilexios Buscar participantes @@ -855,18 +862,18 @@ Descubrir canales Buscar canales Posible intrusión na intimidade! - search.jabber.network.

Ao utilizar esta función transmitirá o seu enderezo IP e termos de busca a ese servizo. Lea a súa Política de Intimidade para máis información.]]>
+ search.jabber.network.

Ao utilizar esta función transmitirá o teu enderezo IP e termos de busca a ese servizo. Le a súa Política de Privacidade para máis información.]]>
Xa teño unha conta Engadir conta existente Rexistrar unha nova conta Esto semella un enderezo de dominio Engadir igualmente Esto semella o enderezo de un canal - Compartir ficheiros de respaldo + Compartir ficheiros de apoio Respaldar Conversations Evento - Abrir respaldo - O ficheiro seleccionado non é un ficheiro de respaldo Conversations + Abrir copia de apoio + O ficheiro seleccionado non é un ficheiro de apoio Conversations Esta conta xa foi configurada Introduza o contrasinal de esta conta Non se puido completar a acción @@ -877,8 +884,37 @@ Servidor local A maioría das usuarias debería escoller \'jabber.network\' para obter mellores suxestións desde o ecosistema público XMPP. Método de descubrimento de canles - Respaldo + Copia de apoio Acerca de + Activa unha conta por favor + Facer unha chamada + Chamada entrante + Videochamada entrante + Conectando + Conectado + Aceptando a chamada + Rematando a chamada + Responder + Rexeitar + Localizando dispositivos + Sonando + Ocupado + Non se pode establecer a chamada + Chamada cortada + Fallo na aplicación + Colgar + Chamada en curso + Videochamada en curso + Desactivar Tor para facer chamadas + Chamada entrante + Conversa de · %s + Chamada realizada + Conversa de · %s + Chamada perdida + Chamada de audio + Chamada de vídeo + O micrófono non está dispoñible + Só podes manter unha chamada en cada momento. Ver %1$d Participante Ver %1$d Participantes diff --git a/src/main/res/values-h360dp/dimens.xml b/src/main/res/values-h360dp/dimens.xml index 45d76ced5..49209bdfc 100644 --- a/src/main/res/values-h360dp/dimens.xml +++ b/src/main/res/values-h360dp/dimens.xml @@ -1,4 +1,5 @@ 16dp 128dp + 64dp diff --git a/src/main/res/values-h500dp/dimens.xml b/src/main/res/values-h500dp/dimens.xml index a153bff54..40e920fd0 100644 --- a/src/main/res/values-h500dp/dimens.xml +++ b/src/main/res/values-h500dp/dimens.xml @@ -1,4 +1,5 @@ 24dp 192dp + 96dp diff --git a/src/main/res/values-hu/strings.xml b/src/main/res/values-hu/strings.xml index b7d629de3..93f2ada8e 100644 --- a/src/main/res/values-hu/strings.xml +++ b/src/main/res/values-hu/strings.xml @@ -116,8 +116,10 @@ Rezegés új üzenet érkezésekor LED értesítés Értesítési fény villogása új üzenet érkezésekor - Csengőhang - Hang lejátszása új üzenet érkezésekor + Csengőhang + Értesítési hang + Értesítési hang új üzeneteknél + Csengőhang bejövő hívásnál Türelmi idő Az időtartam, amíg az értesítések némítva vannak az egyéb eszközei egyikén történt tevékenység észlelése után. Speciális @@ -190,6 +192,7 @@ XEP-0191: Tiltóparancs XEP-0237: Névsorverziózás XEP-0198: Adatfolyam-kezelés + XEP-0215: Külső szolgáltatás felderítése XEP-0163: PEP (profilképek / OMEMO) XEP-0363: HTTP fájlfeltöltés XEP-0357: Leküldés @@ -645,7 +648,7 @@ A megfelelő beszélgetések lezárultak. Partner tiltva. Értesítések idegenektől - Értesítés az idegenektől kapott üzenetekről. + Értesítés az idegenektől fogadott üzenetekről és hívásokról. Üzenet érkezett egy idegentől Idegen tiltása Teljes tartomány tiltása @@ -744,10 +747,14 @@ Kapcsolódási problémák Ezt az értesítési kategóriát egy értesítés megjelenítéséhez használják abban az esetben, ha probléma merül fel a fiókhoz való kapcsolódásnál. Üzenetek + Hívások Üzenetek + Bejövő hívások + Kimenő hívások Csendes üzenetek Ezt az értesítési csoportot olyan értesítések megjelenítéséhez használják, amelyek nem aktiválhatnak hangot. Például ha aktívvá válik egy másik eszközön (türelmi idő). - Értesítési beállítások + Üzenet értesítésének beállításai + Bejövő hívások értesítésnek beállításai Fontosság, hang, rezgés Videó tömörítése Média megtekintése @@ -879,6 +886,35 @@ Csatornafelderítés módszere Biztonsági mentés Névjegy + Engedélyezzen egy fiókot + Hívás indítása + Bejövő hívás + Bejövő videohívás + Kapcsolódás + Kapcsolódva + Hívás elfogadása + Hívás befejezése + Válasz + Elutasítás + Eszközök keresése + Csörgetés + Elfoglalt + Nem lehet kapcsolódni a híváshoz + Visszavont hívás + Alkalmazáshiba + Lerakás + Kimenő hívás + Kimenő videohívás + Tor letiltása a hívások indításához + Bejövő hívás + Bejövő hívás · %s + Kimenő hívás + Kimenő hívás · %s + Nem fogadott hívás + Hanghívás + Videohívás + A mikrofonja nem érhető el + Egyszerre csak egy hívásban vehet részt. %1$d résztvevő megtekintése %1$d résztvevő megtekintése diff --git a/src/main/res/values-id/strings.xml b/src/main/res/values-id/strings.xml index fb098f521..315d682b2 100644 --- a/src/main/res/values-id/strings.xml +++ b/src/main/res/values-id/strings.xml @@ -90,8 +90,6 @@ Getar Aktifkan getar ketika pesan masuk Notifikasi LED - Nada dering - Deringkan ketika pesan masuk Lanjutan Jangan kirim laporan kerusakan Dengan mengirimkan kesalahan Anda membantu pengembangan Aplikasi Conversations diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml index 5c276ef16..53293c15a 100644 --- a/src/main/res/values-it/strings.xml +++ b/src/main/res/values-it/strings.xml @@ -116,8 +116,10 @@ Vibra quando arriva un nuovo messaggio Notifica LED Luce di notifica lampeggiante quando arriva un nuovo messaggio - Suono di notifica - Esegui un suono quando arriva un nuovo messaggio + Suoneria + Suono di notifica + Suono di notifica per i nuovi messaggi + Suoneria per chiamate in arrivo Periodo di grazia Il periodo di tempo in cui le notifiche vengono silenziate dopo aver rilevato attività su uno dei tuoi altri dispositivi. Avanzate @@ -190,6 +192,7 @@ XEP-0191: Blocking Command XEP-0237: Roster Versioning XEP-0198: Stream Management + XEP-0215: Scoperta di servizi esterni XEP-0163: PEP (Avatars / OMEMO) XEP-0363: HTTP File Upload XEP-0357: Push @@ -645,7 +648,7 @@ Chiuse le relative conversazioni. Contatto bloccato. Notifiche da sconosciuti - Notifica i messaggi ricevuti da sconosciuti. + Notifica messaggi e chiamate ricevuti da sconosciuti. Ricevuto messaggio da uno sconosciuto Blocca sconosciuto Blocca intero dominio @@ -744,10 +747,14 @@ Problemi di connettività Questa categoria di notifiche è usata per mostrare un notifica in caso si verifichi un problema nella connessione ad un account. Messaggi + Chiamate Messaggi + Chiamate in arrivo + Chiamate in uscita Messaggi silenziosi Questo gruppo di notifiche è usato per mostrare notifiche che non devono riprodurre alcun suono. Ad esempio mentre si è attivi su un altro dispositivo (Periodo di grazia). - Impostazioni di notifica + Impostazioni di notifica dei messaggi + Impostazioni di notifica delle chiamate in arrivo Importanza, suono, vibrazione Compressione video Vedi i media @@ -879,6 +886,35 @@ Metodo di scoperta canali Backup Al riguardo + Devi attivare un account + Chiama + Chiamata in arrivo + Chiamata video in arrivo + Connessione + Connesso + Accettazione chiamata + Chiusura chiamata + Rispondi + Rifiuta + Localizzazione dispositivi + Sta squillando + Occupato + Impossibile connettere la chiamata + Chiamata ritirata + Errore dell\'applicazione + Riaggancia + Chiamata in corso + Chiamata video in corso + Disattiva Tor per le chiamate + Chiamata in arrivo + Chiamata in arrivo · %s + Chiamata in uscita + Chiamata in uscita · %s + Chiamata persa + Chiamata vocale + Chiamata video + Il tuo microfono non è disponibile + Puoi fare solo una chiamata alla volta. Vedi %1$d partecipante Vedi %1$d partecipanti diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml index 93c4b7473..91539d8bc 100644 --- a/src/main/res/values-ja/strings.xml +++ b/src/main/res/values-ja/strings.xml @@ -112,8 +112,6 @@ 新しいメッセージが届いたときに振動します LED 通知 新しいメッセージが届いたときに通知ライトを点滅します - 着信音 - 新しいメッセージが届いたときにサウンドを再生します 猶予期間 詳細 クラッシュレポートを送信しない @@ -587,7 +585,6 @@ 対応する会話が閉じられました。 連絡先をブロックしました 知らない人からの通知 - 知らない人から受信したメッセージを通知します。 知らない人からメッセージを受け取りました 見知らぬ人をブロック ドメイン全体をブロック diff --git a/src/main/res/values-ko/strings.xml b/src/main/res/values-ko/strings.xml index 536a59753..443957ad5 100644 --- a/src/main/res/values-ko/strings.xml +++ b/src/main/res/values-ko/strings.xml @@ -95,8 +95,6 @@ 새 메세지 도착시 진동 LED 알림 새 메세지 도착시 LED 깜빡이기 - 알림음 - 새 메세지 도착시 알림음 재생 유예기간 고급 충돌 보고서 보내지 않음 @@ -469,5 +467,4 @@ OMEMO 키를 검증 이 장치의 자격 증명을 삭제 하시겠습니까?\n장치와 해당 장치에서 메시지는 신뢰할 수없는 것으로 표시됩니다. 설정된 기간보다 오래된 메시지를 장치에서 자동으로 삭제합니다. - 모르는 사람으로부터 받은 메시지를 알립니다. diff --git a/src/main/res/values-land/bools.xml b/src/main/res/values-land/bools.xml new file mode 100644 index 000000000..1aa953fcc --- /dev/null +++ b/src/main/res/values-land/bools.xml @@ -0,0 +1,4 @@ + + + false + diff --git a/src/main/res/values-land/dimens.xml b/src/main/res/values-land/dimens.xml new file mode 100644 index 000000000..ecdfd32f2 --- /dev/null +++ b/src/main/res/values-land/dimens.xml @@ -0,0 +1,4 @@ + + 96dp + 128dp + \ No newline at end of file diff --git a/src/main/res/values-nb-rNO/strings.xml b/src/main/res/values-nb-rNO/strings.xml index c55cafd36..061d6501c 100644 --- a/src/main/res/values-nb-rNO/strings.xml +++ b/src/main/res/values-nb-rNO/strings.xml @@ -102,8 +102,6 @@ Vibrer når en ny melding ankommer LED-merknad Blink merknadslyset når en ny melding ankommer - Ringetone - Spill en lyd når en ny melding ankommer Fristperiode Avansert Aldri send kræsjrapporter @@ -529,7 +527,6 @@ Samsvarende samtaler lukket. Kontakt blokkert. Varslinger fra fremmede - Vis varsel for meldinger mottatt fra fremmede. Mottok melding fra fremmed Blokker fremmed Blokker hele domenet diff --git a/src/main/res/values-nl/strings.xml b/src/main/res/values-nl/strings.xml index 7e1201df2..8ffc8591c 100644 --- a/src/main/res/values-nl/strings.xml +++ b/src/main/res/values-nl/strings.xml @@ -116,8 +116,6 @@ Trillen wanneer een nieuw bericht ontvangen wordt LED-melding Meldingslicht knipperen wanneer een nieuw bericht ontvangen wordt - Meldingstoon - Geluid afspelen wanneer een nieuw bericht ontvangen wordt Uitstelperiode Geavanceerd Verstuur nooit crashrapportages @@ -507,6 +505,7 @@ Meldingen gepauzeerd Afbeeldingscompressie Altijd + Enkel grote afbeeldingen Batterij-optimalisaties ingeschakeld Je apparaat voert sterke batterij-optimalisaties uit op Conversations, die kunnen leiden tot vertraagde meldingen of zelfs verlies van berichten.\nHet is aangeraden deze optimalisaties uit te schakelen. Je apparaat voert sterke batterij-optimalisaties uit op Conversations, die kunnen leiden tot vertraagde meldingen of zelfs verlies van berichten.\nJe zal nu gevraagd worden deze optimalisaties uit te schakelen. @@ -551,6 +550,7 @@ Privacy Thema Kies het kleurenpalet + Automatisch Licht thema Donker thema Verbinden met OpenKeychain mislukt @@ -638,7 +638,6 @@ Bijbehorende gesprekken gesloten. Contact geblokkeerd. Meldingen van onbekenden - Melding bij berichten van onbekenden. Bericht ontvangen van onbekende Vreemde blokkeren Volledig domein blokkeren @@ -740,7 +739,6 @@ Berichten Stille berichten Deze meldingscategorie wordt gebruikt om meldingen weer te geven die geen geluid mogen maken. Bijvoorbeeld, indien actief op een ander apparaat (uitstelperiode). - Meldingsinstellingen Belang, geluid, trillen Videocompressie Media bekijken @@ -864,4 +862,6 @@ Voer het wachtwoord voor deze account in Kan deze actie niet uitvoeren Deelnemen aan openbaar kanaal… + Lokale server + Over diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml index 746e8022e..aa02a5841 100644 --- a/src/main/res/values-pl/strings.xml +++ b/src/main/res/values-pl/strings.xml @@ -116,8 +116,10 @@ Wibruj gdy nadejdzie wiadomość Powiadomienie diodą LED Migaj lampką powiadamiającą gdy nadejdzie wiadomość - Dzwonek - Odtwórz dźwięk gdy nadejdzie wiadomość + Dzwonek + Dźwięk powiadomień + Dźwięk powiadomień dla nowych wiadomości + Dzwonek dla przychodzących połączeń Czas bez powiadomień Długość czasu kiedy powiadomienia są uśpione po wykryciu aktywności na jednym z twoich innych urządzeń. Zaawansowane @@ -190,6 +192,7 @@ XEP-0191: Blocking Command XEP-0237: Roster Versioning XEP-0198: Stream Management + XEP-0215: Wykrywanie Zewnętrznych Usług XEP-0163: PEP (Awatary / OMEMO) XEP-0363: Przesyłanie plików przez HTTP XEP-0357: Push @@ -661,7 +664,7 @@ Conversations będzie wciąż ograniczał transfer danych, kiedy tylko to jest m Odpowiadające rozmowy zostały zamknięte. Kontakt zablokowany Powiadomienia od nieznajomych - Powiadamiaj o wiadomościach otrzymanych od nieznajomych + Powiadamiaj przy wiadomościach i połączeniach od nieznajomych. Odebrano wiadomość od nieznajomego Zablokuj nieznajomego Zablokuj całą domenę @@ -761,10 +764,14 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż Problemy z połączeniem Ta kategoria powiadomień jest używana aby wyświetlać powiadomienia oznaczające, że Conversations ma problemy z połączeniem. Wiadomości + Połączenia Wiadomości + Połączenia przychodzące + Połączenia wychodzące Ciche wiadomości Ta kategoria powiadomień jest używana aby wyświetlać powiadomienia które nie powodują żadnych dźwięków. Na przykład w ciągu aktywności na innym urządzeniu (okres karencji). - Ustawienia powiadomień + Ustawienia powiadomień wiadomości + Ustawienia powiadomień dla przychodzących połączeń Ważność, Dźwięk, Wibracja Kompresja wideo Pokaż media @@ -896,6 +903,35 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż Metoda odkrywania kanałów Kopia zapasowa O aplikacji + Proszę włączyć konto + Zadzwoń + Połączenie przychodzące + Wideorozmowa przychodząca + Łączenie + Połączony + Akceptowanie połączenia + Kończenie połączenia + Odbierz + Odrzuć + Lokalizowanie urządzeń + Dzwonienie + Zajęty + Nie można połączyć rozmowy + Anulowane połączenie + Błąd aplikacji + Rozłącz + Połączenie wychodzące + Wideorozmowa wychodząca + Wyłącz Tor aby dzwonić + Połączenie przychodzące + Połączenie przychodzące · %s + Połączenie wychodzące + Połączenie wychodzące · %s + Nieodebrane połączenie + Połączenie audio + Połączenie wideo + Twój mikrofon jest niedostępny + Możesz mieć tylko jedno połączenie na raz. Pokaż %1$d uczestnika Pokaż %1$d uczestników diff --git a/src/main/res/values-pt-rBR/strings.xml b/src/main/res/values-pt-rBR/strings.xml index 5cee06e8e..be7b50e08 100644 --- a/src/main/res/values-pt-rBR/strings.xml +++ b/src/main/res/values-pt-rBR/strings.xml @@ -116,8 +116,10 @@ Vibra ao receber uma nova mensagem. Notificação via LED Pisca a luz de notificação ao receber uma nova mensagem. - Toque - Toca um som ao receber uma nova mensagem. + Toque + Som de notificação + Som de notificação para novas mensagens + Toque para chamadas recebidas Período de espera Espaço de tempo em que as notificações serão silenciadas, após detectar atividade em algum dos seus outros dispositivos. Avançado @@ -190,6 +192,7 @@ XEP-0191: Comando de bloqueio XEP-0237: Versionamento da lista de contatos XEP-0198: Gerenciamento de fluxo + XEP-0215: Descoberta de serviço externo XEP-0163: PEP (Avatares / OMEMO) XEP-0363: Envio de arquivos via HTTP XEP-0357: Push @@ -292,6 +295,7 @@ A conversa em grupo foi encerrada Você não está mais nesta conversa em grupo usando a conta %s + hospedado em %s Verificando %s no host HTTP Você não está conectado. Tente novamente mais tarde. Verificar o tamanho de %s @@ -555,6 +559,7 @@ Privacidade Tema Selecione a paleta de cores + Automático Tema claro Tema escuro Não foi possível conectar ao OpenKeychain @@ -642,7 +647,7 @@ As conversas correspondentes foram encerradas. O contato foi bloqueado. Notificações de desconhecidos - Notificar ao receber mensagens de desconhecidos. + Notificar ao receber mensagens e chamadas de desconhecidos. Foi recebida uma mensagem de um desconhecido Bloquear os desconhecidos Bloquear o domínio inteiro @@ -741,10 +746,14 @@ Problemas de conectividade Essa categoria de notificação é utilizada para exibir uma notificação caso exista algum problema de conectividade com uma conta. Mensagens + Chamadas Mensagens + Chamadas recebidas + Chamadas em andamento Silenciar mensagens Essa categoria de notificação é utilizada para exibir notificações que não deveriam gerar nenhum som. Por exemplo, quando estiver ativo em outro dispositivo (Período de Espera). - Configurações de notificações + Configurações das notificações de mensagens + configurações das notificações de chamadas recebidas Importância, som, vibração. Compressão de vídeo Ver mídia @@ -876,6 +885,35 @@ Método de descoberta de canais Backup Sobre + Por favor, habilite uma conta + Fazer chamada + Recebendo chamada + Recebendo chamada de vídeo + Conectando + Conectado + Atendendo chamada + Encerrando chamada + Atender + Dispensar + Procurando dispositivos + Tocando + Ocupado + Não foi possível conectar a chamada + Chamada rejeitada + Falha no aplicativo + Desligar + Atendendo chamada + Atendendo vídeo chamada + Desabilitar o Tor para fazer chamadas + Chamada recebida + Chamada recebida · %s + Chamada realizada + Chamada realizada · %s + Chamada perdida + Chamada de áudio + Chamada de vídeo + Seu microfone não está disponível + Você só pode ter uma chamada de cada vez Ver %1$d participante Ver %1$d participantes diff --git a/src/main/res/values-pt/strings.xml b/src/main/res/values-pt/strings.xml index 30f52a823..b057be2e4 100644 --- a/src/main/res/values-pt/strings.xml +++ b/src/main/res/values-pt/strings.xml @@ -103,8 +103,6 @@ Vibrar quando uma nova mensagem for recebida Notificação LED Piscar luz de notificação quando uma nova mensagem for recebida - Tom de toque - Tocar som quando uma nova mensagem for recebida Avançadas Nunca enviar relatórios de falhas Ao enviar os stack traces você está a ajudar ao desenvolvimento contínuo de Conversations diff --git a/src/main/res/values-ro-rRO/strings.xml b/src/main/res/values-ro-rRO/strings.xml index 18a99a2aa..1131da162 100644 --- a/src/main/res/values-ro-rRO/strings.xml +++ b/src/main/res/values-ro-rRO/strings.xml @@ -116,8 +116,10 @@ Vibrează când este primit un mesaj nou Notificare LED Clipește lumina de notificare atunci când este primit un mesaj nou - Sunet de notificare - Notificare sonoră atunci când este primit un mesaj nou + Ton de apel + Sunet de notificare + Sunet de notificare pentru mesaje noi + Ton pentru apelul primit Perioadă de grație Durata de timp cât notificările sunt ascunse după ce s-a observat activitate pe un alt dispozitiv al dumneavoastră. Opțiuni avansate @@ -190,6 +192,7 @@ XEP-0191: Comandă blocare XEP-0237: Creare de versiuni listă XEP-0198: Management flux + XEP-0215: Descoperirea serviciilor externe XEP-0163: PEP (Avatare / OMEMO) XEP-0363: Încărcare fișiere prin HTTP XEP-0357: Push @@ -654,7 +657,7 @@ sau chiar pierderea mesajelor.\nÎn continuare veți fi rugați să dezactivați Conversațiile corespunzătoare au fost închise. Contact blocat. Notificări de la persoane necunoscute - Primire notificări și pentru mesajele de la persoane care nu sunt în lista de contacte. + Primire notificări pentru mesaje și apeluri de la persoane care nu sunt în lista de contacte. Mesaj primit de la o persoană necunoscută Blocare contact necunoscut Blocare tot domeniu @@ -753,10 +756,14 @@ sau chiar pierderea mesajelor.\nÎn continuare veți fi rugați să dezactivați Probleme de conectare Această categorie de notificări este folosită pentru a arăta o notificare în cazul în care există o problemă la conectarea unui cont. Mesaje + Apeluri Mesaje + Apeluri primite + Apeluri în curs Mesaje silențioase Acest grup de notificări este folosit pentru a arăta notificări care nu emit sunete. De exemplu atunci când sunteți activi pe un alt dispozitiv (Perioada de grație). - Setări notificări + Setări de notificare ale mesajelor + Setări de notificare ale apelurilor primite Importanță, sunete, vibrații Compresie video Vizualizare fișiere media @@ -888,6 +895,35 @@ sau chiar pierderea mesajelor.\nÎn continuare veți fi rugați să dezactivați Metoda de descoperire a canalelor Copie de siguranță Despre + Va rugăm să activați un cont + Apelează + Apel primit + Apel video primit + Conectare + Conectat + Se acceptă apelul + Se încheie apelul + Răspunde + Respinge + Localizare dispozitive + Sună + Ocupat + Nu s-a putut conecta apelul + Apel anulat + Eroare de aplicație + Închide + Apel în curs + Apel video în curs + Dezactivați Tor pentru a face apeluri + Apel primit + Apel primit · %s + Apel efectuat + Apel efectuat · %s + Apel pierdut + Apel audio + Apel video + Microfonul nu este disponibil + Puteți avea un singur apel simultan. Arată %1$d participant Arată %1$d participanți diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml index a6e369994..e4031d488 100644 --- a/src/main/res/values-ru/strings.xml +++ b/src/main/res/values-ru/strings.xml @@ -3,6 +3,7 @@ Настройки Новая беседа Управление аккаунтами + Управление аккаунтом Закрыть текущую беседу Сведения о контакте Подробности конференции @@ -16,6 +17,8 @@ Разблокировать контакт Заблокировать домен Разблокировать домен + Заблокировать участника + Разблокировать участника Управление аккаунтами Настройки Поделиться @@ -43,6 +46,7 @@ Заблокировать всех пользователей домена %s? Разблокировать всех пользователей домена %s? Контакт заблокирован + Заблокирован Вы хотите удалить %s из избранного? Беседы, связанные с данной закладкой будут сохранены Создать новый аккаунт на сервере Изменить пароль на сервере @@ -111,14 +115,12 @@ Вибрировать, когда приходят новые сообщения Светодиодное уведомление Мерцание индикатора при получении нового сообщения - Звук уведомления - Звук при поступлении новых сообщений Грейс-период Дополнительно Не отправлять отчёты об ошибках Отправляя отчёты об ошибках, вы помогаете исправить и улучшить Conversations Отчёты о получении - Позволяет вашим контактам видеть когда вы получили и прочитали их сообщения + Позволяет вашим контактам видеть, когда вы получили и прочитали их сообщения Интерфейс Принять Произошла ошибка @@ -221,6 +223,8 @@ Удалить закладку Уничтожить конференцию Уничтожить канал + Не удалось уничтожить конференцию + Не удалось уничтожить канал Редактировать тему конференции Тема Вход в конференцию… @@ -267,6 +271,7 @@ Включить режим «тихих часов» Уведомления будут отключены во время «тихих часов» Другие + Синхронизировать с закладками OMEMO отпечаток скопирован в буфер обмена! Вы заблокированы из этой конференции Эта конференция — только для участников @@ -315,6 +320,7 @@ %s предлагается скачать Отменить передачу передача файла не удалась + передача файла отменена Файл был удалён Не найдено приложения для открытия файла Не найдено приложения, способного открыть эту ссылку @@ -353,10 +359,13 @@ Снять административные права Убрать из конференции Не удалось изменить принадлежность %s - Заблокировать из конференции + Заблокировать в конференции Заблокировать Не удалось сменить роль %s + Настройки приватной конференции + Настройки публичного канала Приватная + Сделать XMPP адрес видимым для всех Вы не участвуете Настройки конференции изменены! Не удалось изменить настройки конференции @@ -384,12 +393,14 @@ %s печатают... %s перестали печатать Оповещения о наборе - Позволяет вашим контактам видеть когда вы пишете им новое сообщение + Позволяет вашим контактам видеть, когда вы пишете им новое сообщение Отправить местоположение Показать местоположение Не найдено приложений для отображения местоположения Местоположение Беседа окончена + Покинул приватную конференцию + Покинул публичный канал Не доверять системным УЦ Все сертификаты должны быть подтверждены вручную Удалить сертификаты @@ -477,6 +488,7 @@ Уведомления приостановлены Сжатие изображений Всегда + Только большие изображения Оптимизации энергопотребления разрешены Ваше устройство использует сильные оптимизации энергопотребления, что может привести к задержке уведомлений и даже потере сообщений.\nРекомендуется их отключить. Ваше устройство использует сильные оптимизации энергопотребления, что может привести к задержке уведомлений и даже потере сообщений.\nСейчас появится предложение их отключить. @@ -515,10 +527,11 @@ Средний Длинный Оповещать о взаимодействии - Извещать контакты когда вы используете Conversations + Извещать контакты, когда вы используете Conversations Приватность Тема Выбрать цветовую палитру + Автоматически Светлая тема Тёмная тема Не удалось подключиться к OpenKeychain @@ -541,7 +554,7 @@ Создать заново OMEMO ключи. Необходимо повторное подтверждение. Используйте только в крайнем случае. Удалить отмеченные Вы должны подключиться для публикации аватара. - Текст ошибки + Показать текст ошибки Текст ошибки Режим экономии трафика включен Ваша операционная система не позволяет Conversations получать доступ в Интернет в фоновом режиме. Для получения уведомлений вы должны разрешить Conversations неограниченный доступ когда режим экономии трафика включен.\nConversations постарается сохранить трафик когда это возможно. @@ -617,7 +630,6 @@ Соответствующие беседы закрыты. Контакт заблокирован Уведомления от неизвестных контактов - Уведомлять о сообщениях от незнакомых контактов. Получено сообщение от неизвестного контакта Заблокировать неизвестный контакт Заблокировать весь домен @@ -640,6 +652,7 @@ Сообщение Личные сообщения выключены Принять Неизвестный Сертификат? + Вы все равно хотите подключиться? Прокручивать вниз Прокручивать вниз после отправки сообщения Редактировать статусное сообщение @@ -652,6 +665,7 @@ OMEMO будет всегда использоваться для одиночных бесед и закрытых конференций. OMEMO будет использоваться по умолчанию для новых бесед. OMEMO нужно будет явно включать для новых бесед. + Создать ярлык Размер шрифта Относительный размер шрифта используемый в приложении. Включено по умолчанию @@ -676,17 +690,42 @@ Копировать XMPP-адрес Быстрый поиск На экране \"Начать беседу\" открывать клавиатуру и ставить курсор в поле поиска - Настройки уведомлений + Аватар конференции + Имя контакта + Никнейм + Название + Название конференции + Эта конференция была уничтожена + Проблемы с подключением + Сжатие видео Просмотр медиа + Участники Качество видео Низкое качество означает меньшие файлы Среднее (360p) Высокое (720р) + отменено + Функция не реализована + Неверный код страны + Выберите страну + номер телефона + Слишком много попыток + Вы используете устаревшую версию приложения + Ваше имя + Введите ваше имя Оригинал (без сжатия) + Открыть с помощью... + Выбрать аккаунт + Восстановить из резервной копии + Восстановить + Невозможно восстановить из резервной копии + Невозможно расшифровать резервную копию. Правильно ли введен пароль? Создать конференцию Присоединиться к каналу Создать закрытую конференцию Создать публичный канал + Название канала + Создание публичного канала... Найти каналы У меня уже есть аккаунт Добавить существующий аккаунт diff --git a/src/main/res/values-sr/strings.xml b/src/main/res/values-sr/strings.xml index e5579ae9c..7c369d2de 100644 --- a/src/main/res/values-sr/strings.xml +++ b/src/main/res/values-sr/strings.xml @@ -105,8 +105,6 @@ Вибрирање кад стигне нова порука ЛЕД светло Трептање ЛЕД светла кад стигне нова порука - Звук - Пуштање звука кад стигне нова порука Период одгоде Напредно Никад не шаљи извештаје о паду @@ -570,7 +568,6 @@ Одговарајуће преписке затворене. Контакт блокиран. Обавештења од непознатих - Обавештења за поруке од непознатих. Примљена порука од незнанца Блокирај странца Блокирај читав домен @@ -618,6 +615,5 @@ Поруке Поруке Тихе поруке - Поставке обавештавања Видео компресија diff --git a/src/main/res/values-sv/strings.xml b/src/main/res/values-sv/strings.xml index f43629a83..fd4e96963 100644 --- a/src/main/res/values-sv/strings.xml +++ b/src/main/res/values-sv/strings.xml @@ -114,8 +114,6 @@ Vibrera när meddelande tagits emot LED notifieringar Blinka med notifieringsljuset när ett meddelande tagits emot - Meddelandesignal - Spela ljud när meddelande tagits emot Notifieringsfrist Avancerat Skicka aldrig krasch-rapporter @@ -583,7 +581,6 @@ Motsvarande konversationer är stängda. Kontakt blockerad. Notifieringar från främlingar - Notifiera för meddelanden från främlingar. Mottagna meddelanden från främlingar Blockera främling Blockera hel domän diff --git a/src/main/res/values-tr-rTR/strings.xml b/src/main/res/values-tr-rTR/strings.xml index d53969318..2f0ff43ac 100644 --- a/src/main/res/values-tr-rTR/strings.xml +++ b/src/main/res/values-tr-rTR/strings.xml @@ -95,8 +95,6 @@ Yeni ileti geldiğinde titret LED Bildirimi Yeni bir ileti geldiğinde bildirim ışığı yanıp sönsün - Zil sesi - Yeni bir ileti geldiğinde sesli bildir Mühlet Gelişmiş Asla çöküş raporu gönderme @@ -488,6 +486,5 @@ Konuşma sonlandı Kişi engellendi. Yabancılardan bildirimler - Yabancılardan alınan iletileri bildir. Yabancıdan alınmış ileti diff --git a/src/main/res/values-uk/strings.xml b/src/main/res/values-uk/strings.xml index b43305870..66b223c5f 100644 --- a/src/main/res/values-uk/strings.xml +++ b/src/main/res/values-uk/strings.xml @@ -17,6 +17,8 @@ Розблокувати контакт Заблокувати домен Розблокувати домен + Заблокувати учасника + Розблокувати учасника Впорядкувати облікові записи Налаштування Поділитися в Розмови @@ -28,6 +30,7 @@ щойно 1 хвилину тому %d хвилин тому + %d непрочитаних розмов відправляю… Розшифровую повідомлення. Зачекайте, будь ласка… Повідомлення, зашифроване OpenPGP @@ -113,9 +116,8 @@ Вібрувати, коли приходять нові повідомлення Індикація LED Блимати світловим індикатором, коли надходить нове повідомлення - Мелодія дзвінка - Грати звук, коли надходить нове повідомлення Період очікування + Час, протягом якого не буде сигналу про нові сповіщення, після дій користувача на іншому пристрої. Розширені Не надсилати звіти про збої Надсилаючи траси стеку викликів Ви допомагаєте розробці Розмов, яка продовжується @@ -151,6 +153,7 @@ Ім\'я користувача вже використовується Реєстрацію виконано Сервер не підтримує режстрацію + Недійсний реєстраційний токен Узгодження TLS не відбулося Порушення політики Несумісний сервер @@ -288,6 +291,7 @@ Цю групу закрили Ви більше не берете участь в цій групі використовується обліковий запис %s + розміщений на %s Перевіряю %s на хості HTTP Ви не з\'єднані. Спробуйте ще пізніше. Перевірити %s розмір @@ -329,6 +333,7 @@ %s запропоновано для завантаження Припинити передачу передача файла не вдалася + передачу файлу перервано Файл видалено Не знайдено програми для відкриття файла Не знайдено програми, щоб відкрити посилання @@ -507,7 +512,9 @@ Сповіщення вимкнено Сповіщення призупинено Стиснення зображень + Підказка: Обирайте \"Вибрати файл\" замість \"Вибрати зображення\", щоб надіслати окремі зображення без стиснення в обхід цього налаштування. Завжди + Лише великі зображення Оптимізацію батареї задіяно Ваш пристрій здійснює деяку агресивну оптимізацію Розмов для збереження заряду батареї, яка може призвести до затримки сповіщення або навіть втрати повідомлень.\nРекомендовано відключити цю оптимізацію. Ваш пристрій здійснює деяку агресивну оптимізацію Розмов для збереження заряду батареї, яка може призвести до затримки сповіщення або навіть втрати повідомлень.\nПросимо Вас зараз відключити цю оптимзацію. @@ -553,6 +560,7 @@ Приватність Тема Вибрати палітру кольорів + Автоматично Світла тема Темна тема Не можу зв\'язатися з OpenKeychain @@ -652,7 +660,6 @@ Відповідні розмови закрито. Контакт заблоковано. Сповіщення від незнайомців - Сповіщувати про повідомлення від незнайомців. Отримано повідомлення від незнайомця Заблокувати незнайомця Заблокувати весь домен @@ -754,7 +761,6 @@ Повідомлення Тихі повідомлення Ця група сповіщень показує сповіщення, які не повинні супроводжуватися звуком. Наприклад, у разі активності на іншому пристрої (період очікування). - Налаштування сповіщень Важливість, звук, вібрація Стиснення відео Перегляд медіа @@ -862,10 +868,35 @@ Знайти канали Шукати канали Можливе порушення приватності! + search.jabber.network.

Користуючись ним, Ви передає Вашу IP адресу та пошукові запити цьому сервісу. Перегляньте їхню політику конфіденційності, щоб отримати більше інформації.]]>
Я вже маю обліковий запис Додати наявний обліковий запис Зареєструвати новий обліковий запис Це схоже на ім\'я домену Додати все одно Це схоже на адресу каналу + Поділитися резервними копіями + Резервне копіювання розмов + Подія + Відкрити резервну копію + Обраний файл не є резервною копією цієї програми + Цей обліковий запис уже налаштовано + Будь ласка, введіть пароль цього облікового запису + Не можу виконати цю дію + Приєднатися до публічного каналу… + Програма, яка надає доступ, не надала дозволу на доступ до цього файлу. + + jabber.network + Локальний сервер + Пересічному користувачеві слід обрати «jabber.network» для кращих пропозицій з усієї публічної XMPP екосистеми. + Спосіб пошуку каналів + Резервне копіювання + Про + Будь ласка, увімкніть обліковий запис + + Переглянути %1$d учасника + Переглянути %1$d учасників + Переглянути %1$d учасників + Переглянути %1$d учасників + diff --git a/src/main/res/values-vi/strings.xml b/src/main/res/values-vi/strings.xml index 82bbfc97f..92f4a508a 100644 --- a/src/main/res/values-vi/strings.xml +++ b/src/main/res/values-vi/strings.xml @@ -95,8 +95,6 @@ Rung khi có tin nhắn mới Thông báo đèn LED Chớp đèn thông báo khi có tin nhắn mới - Âm báo - Chơi nhạc báo khi có tin nhắn mới Thời gian gia hạn thông báo Nâng cao Không bao giờ gửi báo cáo dừng chạy diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml index 1b8078c27..18a8886fc 100644 --- a/src/main/res/values-zh-rCN/strings.xml +++ b/src/main/res/values-zh-rCN/strings.xml @@ -116,8 +116,6 @@ 收到新消息时震动 LED 灯提示 收到新消息时闪烁通知灯 - 铃声 - 收到新消息时响铃 静默期限 在您的其他设备之一上检测到活动之后,时间通知的长度将被静音。 高级 @@ -526,6 +524,7 @@ 安全错误:文件访问权限无效 未找到可以分享此链接的应用 分享链接…… +
您注册了电话号码,Quicksy就会根据您的通讯录中的电话号码自动为您建议可能的联系人

签署即表示您同意我们的隐私政策。]]>
同意 & 继续 此向导将为您在conversations.im¹上创建一个账户。\n您的联系人可以通过您的XMPP完整地址与您聊天。 您的XMPP完整地址将是:%s @@ -638,7 +637,6 @@ 相应的对话已关闭。 联系人已屏蔽 陌生人也通知 - 收到陌生人信息时通知 已收到陌生人的信息 屏蔽陌生人 屏蔽整个域名 @@ -740,7 +738,6 @@ 消息 无声消息 此通知组用于显示不应触发任何声音的通知。 例如,当在另一个设备上激活时(宽限期)。 - 通知设置 重要性,声音,振动 视频压缩 查看媒体文件 @@ -848,6 +845,7 @@ 发现群聊 搜索群聊 可能侵犯隐私! + search.jabber.network。

的第三方服务。使用此功能会将您的IP地址和搜索字词传输到该服务。 有关更多信息,请参见其Privacy Policy。]]>
我已有账户 添加已有账户 注册新账户 @@ -865,10 +863,13 @@ 加入公开群聊 分享程序没有访问文件的权限 + jabber.network 本地服务器 + 大多数用户应该选择“ jabber.network”以从整个XMPP生态系统中获得更好的建议。 频道发现方法 备份 关于 + 请启用一个帐户 查看%1$d成员 diff --git a/src/main/res/values-zh-rTW/strings.xml b/src/main/res/values-zh-rTW/strings.xml index 47c9b400f..1d7f341f5 100644 --- a/src/main/res/values-zh-rTW/strings.xml +++ b/src/main/res/values-zh-rTW/strings.xml @@ -96,8 +96,6 @@ 收到新訊息時震動 LED 燈通知 收到新訊息時閃爍通知燈 - 鈴聲 - 收到新訊息時響鈴 靜默期限 高級 總不發送崩潰報告 @@ -494,7 +492,6 @@ 關閉相關的對話了。 已經封鎖聯絡人了。 陌生人訊息通知 - 當收到來自陌生人的訊息時顯示通知。 接受來自陌生人的訊息 封鎖陌生人 封鎖整個網域 diff --git a/src/main/res/values/attrs.xml b/src/main/res/values/attrs.xml index ce15d0013..6d812e219 100644 --- a/src/main/res/values/attrs.xml +++ b/src/main/res/values/attrs.xml @@ -28,6 +28,7 @@ + @@ -44,6 +45,10 @@ + + + + @@ -85,6 +90,7 @@ + diff --git a/src/main/res/values/bools.xml b/src/main/res/values/bools.xml new file mode 100644 index 000000000..0799afb3f --- /dev/null +++ b/src/main/res/values/bools.xml @@ -0,0 +1,4 @@ + + + true + \ No newline at end of file diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index aa89dbd30..3b52f4c24 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -19,6 +19,7 @@ #ff282828 #ff141414 #fff44336 + #ffD32F2F #ffd50000 #ffff8a80 #ffc62828 diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index dd7b12aa0..9f07f3f34 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -12,6 +12,7 @@ true false content://settings/system/notification_sound + content://settings/system/ringtone 144 524288 auto diff --git a/src/main/res/values/dimens.xml b/src/main/res/values/dimens.xml index 9eb4b102b..215f11a14 100644 --- a/src/main/res/values/dimens.xml +++ b/src/main/res/values/dimens.xml @@ -1,38 +1,43 @@ - - 8dp - 8dp - 16dp - 8dp - 8dp - 12dp - 48dp - 11sp - 224dp - 16dp + + 8dp + 8dp + 16dp + 8dp + 8dp + 12dp + 48dp + + 11sp + 224dp + 16dp - 80dp - 56dp - 96dp - 4dp + 80dp + 56dp + 96dp + 4dp - 8dp - 96dp - 32dp - 48dp - 56dp - 56dp + 8dp + 96dp + 48dp + 32dp + 48dp + 56dp + 56dp - 4dp - 4dp + 4dp + 4dp - - 4dp - 8dp + + 4dp + 8dp - 1200dp + 1200dp - 0.12 + 0.12 - 256dp + 256dp + + 128dp + 96dp diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 526a57d8a..769610860 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -116,8 +116,10 @@ Vibrate when a new message arrives LED Notification Blink notification light when a new message arrives - Ringtone - Play sound when a new message arrives + Ringtone + Notification sound + Notification sound for new messages + Ringtone for incoming call Grace Period The length of time notifications are silenced after detecting activity on one of your other devices. Advanced @@ -190,6 +192,7 @@ XEP-0191: Blocking Command XEP-0237: Roster Versioning XEP-0198: Stream Management + XEP-0215: External Service Discovery XEP-0163: PEP (Avatars / OMEMO) XEP-0363: HTTP File Upload XEP-0357: Push @@ -647,7 +650,7 @@ Corresponding conversations closed. Contact blocked. Notifications from strangers - Notify for messages received from strangers. + Notify for messages and calls received from strangers. Received message from stranger Block stranger Block entire domain @@ -746,10 +749,14 @@ Connectivity Problems This notification category is used to display a notification in case there is a problem connecting to an account. Messages + Calls Messages + Incoming calls + Ongoing calls Silent messages This notification group is used to display notifications that should not trigger any sound. For example when being active on another device (Grace Period). - Notification Settings + Message notification settings + Incoming calls notification settings Importance, Sound, Vibrate Video compression View media @@ -881,6 +888,35 @@ Channel discovery method Backup About + Please enable an account + Make call + Incoming call + Incoming video call + Connecting + Connected + Accepting call + Ending call + Answer + Dismiss + Locating devices + Ringing + Busy + Unable to connect call + Retracted call + Application failure + Hang up + Ongoing call + Ongoing video call + Disable Tor to make calls + Incoming call + Incoming call · %s + Outgoing call + Outgoing call · %s + Missed call + Audio call + Video call + Your microphone is unavailable + You can only have one call at a time. View %1$d Participant View %1$d Participants diff --git a/src/main/res/values/styles.xml b/src/main/res/values/styles.xml index 432f256c0..00ed0b3b5 100644 --- a/src/main/res/values/styles.xml +++ b/src/main/res/values/styles.xml @@ -1,11 +1,14 @@ - + + diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index 6c428f8c1..316980c19 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -15,6 +15,7 @@ @color/green500 @color/red800 @color/black87 + @color/black54 @drawable/search_background_light @drawable/no_results_background_light @@ -31,6 +32,7 @@ 14sp 16sp 20sp + 45sp 16sp 5sp 18sp @@ -52,6 +54,9 @@ @drawable/ic_attach_photo @drawable/ic_attach_record + @drawable/ic_call_black54_24dp + @drawable/ic_videocam_black54_24dp + @drawable/message_bubble_received_white @drawable/message_bubble_sent @drawable/message_bubble_received @@ -93,6 +98,7 @@ @drawable/ic_refresh_black_24dp @drawable/ic_attach_file_white_24dp @drawable/ic_lock_open_white_24dp + @drawable/ic_call_white_24dp @drawable/ic_delete_black_24dp @drawable/ic_search_white_24dp @drawable/ic_lock_open_white_24dp @@ -134,6 +140,7 @@ @color/green500 @color/red500 @color/white + @color/white70 @color/white @@ -145,6 +152,7 @@ 14sp 16sp 20sp + 45sp 16sp 5sp 18sp @@ -159,6 +167,9 @@ @drawable/ic_send_videocam_offline_white @drawable/ic_send_voice_offline_white + @drawable/ic_call_white70_24dp + @drawable/ic_videocam_white70_24dp + @drawable/ic_attach_camera_white @drawable/ic_attach_videocam_white @drawable/ic_attach_document_white @@ -207,6 +218,7 @@ @drawable/ic_refresh_white_24dp @drawable/ic_attach_file_white_24dp @drawable/ic_lock_open_white_24dp + @drawable/ic_call_white_24dp @drawable/ic_delete_white_24dp @drawable/ic_search_white_24dp @drawable/ic_lock_open_white_24dp @@ -234,6 +246,7 @@ 16sp 18sp 22sp + 47sp 18sp 6sp 20sp @@ -245,6 +258,7 @@ 16sp 18sp 22sp + 47sp 18sp 6sp 20sp @@ -256,6 +270,7 @@ 18sp 20sp 24sp + 48sp 20sp 7sp 22sp @@ -267,6 +282,7 @@ 18sp 20sp 24sp + 48sp 20sp 7sp 22sp diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index bce0cc349..981b07670 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -101,9 +101,9 @@ + android:title="@string/pref_message_notification_settings"> + + + + + + + android:summary="@string/pref_notification_sound_summary" + android:title="@string/pref_notification_sound" /> + Quicksy a planté - En envoyant des traces de pile, vous contribuez au développement en cours de Quicks \ Warning: cela utilisera votre compte XMPP pour envoyer la trace de pile au développeur. - Quicksy requiert une application tierce nommée OpenKeychain pour chiffrer et déchiffrer les messages.\n\nOpenKeychain est sous licence GPLv3 et est disponible sur F-Droid et Google Play.\n\n(Veuillez redémarrer Quicksy après l\'installation de l\'app) - Quicksy ne peut pas chiffrer vos messages, car votre contact n’annonce pas sa clé publique. \ N \ nVeuillez demander à votre contact de configurer OpenPGP. - Quicksy ne peut pas chiffrer vos messages car votre contact n\'a pas communiqué sa clef publique.\n\nDemandez-lui de configurer OpenPGP. - Durée d\'inactivité de Quicksy après avoir repéré un changement sur un autre appareil - En envoyant des logs vous aidez le développement de Quicksy. - Quicksy a besoin d\'accéder à un stockage externe - Quicksy a besoin d\'accéder à la caméra - Votre appareil effectue actuellement des optimisations de batterie lourdes sur Quicksy pouvant entraîner des notifications tardives, voire la perte de messages. \ NIl est recommandé de les désactiver. - Votre appareil effectue actuellement des optimisations lourdes de la batterie sur Quicksy, susceptibles de retarder les notifications ou même de faire perdre des messages. \ N \ nVous serez invité à les désactiver. + En envoyant des traces d’appels, vous contribuez au développement en cours de Quicksy\nAvertissement : cela utilisera votre compte XMPP pour envoyer la trace d’appels au développeur. + Quicksy utilise une application tierce nommée OpenKeychain pour chiffrer et déchiffrer les messages et gérer vos clés publiques.\n\nOpenKeychain est sous licence GPLv3 et est disponible sur F-Droid et Google Play.\n\n(Veuillez redémarrer Quicksy après son installation.) + Quicksy ne peut pas chiffrer vos messages car votre contact n’annonce pas sa clé publique.\n\nVeuillez demander à votre contact de configurer OpenPGP. + Quicksy ne peut pas chiffrer vos messages car vos contacts n’annoncent pas leurs clés publiques.\n\nVeuillez demander à vos contacts de configurer OpenPGP. + Durée d’inactivité de Quicksy après avoir repéré un changement sur un autre appareil + En envoyant des traces d’appels, vous aidez le développement de Quicksy + Quicksy a besoin d’accéder au stockage externe + Quicksy a besoin d’accéder à la caméra + Votre appareil applique des optimisations de batterie lourdes sur Quicksy pouvant entraîner des notifications tardives, voire la perte de messages.\nIl est recommandé de les désactiver. + Votre appareil applique des optimisations de batterie lourdes sur Quicksy pouvant entraîner des notifications tardives, voire la perte de messages.\nVous allez maintenant être invité à les désactiver. Faites savoir à tous vos contacts quand vous utilisez Quicksy - Votre système d’exploitation limite votre navigation sur Internet lorsque vous êtes en arrière-plan. Pour recevoir des notifications de nouveaux messages, vous devez autoriser l\'enregistrement des données. - Votre appareil ne prend pas en charge la désactivation de Data Saver pour Quicksy. - Pour continuer à recevoir des notifications, même lorsque l\'écran est éteint, vous devez ajouter Quicksy à la liste des applications protégées. - Quicksy est incapable d\'envoyer des messages cryptés à %1$s. Cela peut être dû au fait que votre contact utilise un serveur ou un client obsolète qui ne peut pas gérer OMEMO. - Quicksy doit avoir accès au microphone - Cette catégorie de notification est utilisée pour afficher une notification permanente indiquant que Quicksy est en cours d\'exécution. + Votre système d’exploitation restreint l’accès à Internet à Quicksy lorsqu’il est en arrière-plan. Pour recevoir les notifications des nouveaux messages reçus, vous devriez accorder à Quicksy un accès illimité lorsque l’économie de la consommation des données est activée.\nQuicksy essaiera quand même d’économiser la consommation lorsque c’est possible. + Votre appareil ne prend pas en charge la désactivation du mode économie de données pour Quicksy. + Pour continuer à recevoir des notifications, même lorsque l’écran est éteint, vous devez ajouter Quicksy à la liste des applications protégées. + Quicksy ne peut pas envoyer des messages chiffrés à %1$s. Cela peut être dû au fait que votre contact utilise un serveur ou un client obsolète qui ne peut pas gérer OMEMO. + Quicksy a besoin d’accéder au microphone + Cette catégorie de notification est utilisée pour afficher une notification permanente indiquant que Quicksy est en cours d’exécution. Photo de profil Quicksy - Quicksy n\'est pas disponible dans votre pays. - Vérification de l\'identité du serveur impossible. + Quicksy n’est pas disponible dans votre pays. + Impossible de vérifier l’identité du serveur. Erreur de sécurité inconnue. - Timeout lors de la connexion au serveur. + Délai expiré lors de la connexion au serveur. diff --git a/src/quicksy/res/values-gl/strings.xml b/src/quicksy/res/values-gl/strings.xml index 9641086fe..99701c741 100644 --- a/src/quicksy/res/values-gl/strings.xml +++ b/src/quicksy/res/values-gl/strings.xml @@ -1,12 +1,12 @@ Quicksy fallou - Ao enviar lotes de rexistro estás a axudar no desenvolvemento de Quicksy\nAviso: vas utilizar a tua conta XMPP para enviar o rexistro ao equipo de desenvolvemento. - Quicksy utiliza unha app de terceiros chamada OpenKeychain para cifrar e descifrar as mensaxes e xestionar a tuas chaves públicas.\n\nOpenKeychain ten licenza GPLv3 e está dispoñible en F-Droid e Google Play.\n\n(Por favor, reinicia Quicksy ao rematar). - Quicksy non pode cifrar as tuas mensaxes porque o teu contacto non publicou as sua chave pública.\n\nPor favor, solicita ao contacto que configure OpenPGP. - Quicksy non pode cifrar as tuas mensaxes porque os teus contactos non están a publicar a súa chave pública.\n\nPor favor, pídelle aos teus contactos que configuren OpenPGP. + Ao enviar trazas do rexistro estás a axudar no desenvolvemento de Quicksy\nAviso: vas utilizar a túa conta XMPP para enviar o rexistro ao equipo de desenvolvemento. + Quicksy utiliza unha app de terceiros chamada OpenKeychain para cifrar e descifrar as mensaxes e xestionar a túas chaves públicas.\n\nOpenKeychain ten licenza GPLv3 e está dispoñible en F-Droid e Google Play.\n\n(Por favor, reinicia Quicksy ao rematar). + Quicksy non pode cifrar as túas mensaxes porque o teu contacto non publicou as súa chave pública.\n\nPor favor, solicita ao contacto que configure OpenPGP. + Quicksy non pode cifrar as túas mensaxes porque os teus contactos non están a publicar a súa chave pública.\n\nPor favor, pídelle aos teus contactos que configuren OpenPGP. O período de tempo que Quicksy permanece acalado tras ver actividade en outro dispositivo - Enviando trazas de rexistro estás axudando ao desenvolvemento de Quicksy + Enviando trazas do rexistro estás axudando ao desenvolvemento de Quicksy Quicksy precisa acceso ao almacenamento externo Quicksy precisa acceso a cámara O teu dispositivo está a realizar optimizacións de batería intensivas con Quicksy que poderían levar a que as notificacións tarden en chegar ou que as mensaxes se perdan.\nRecomendamos desactivalas. diff --git a/src/quicksy/res/values-uk/strings.xml b/src/quicksy/res/values-uk/strings.xml index ab585b8a2..62777fe98 100644 --- a/src/quicksy/res/values-uk/strings.xml +++ b/src/quicksy/res/values-uk/strings.xml @@ -19,4 +19,8 @@ Програма потребує доступу до мікрофона Цей вид сповіщень показує постійне сповіщення про те, що ця програма працює. Зображення профілю для Quicksy - + Ця програма не доступна у Вашій країні. + Автентичність сервера не підтверджено + Невідома помилка безпеки. + Вичерпано час для встановлення з\'єднання із сервером. +