Detailed changes
@@ -1,5 +1,13 @@
# Changelog
+### Version 2.11.0
+
+* Implement Extensible SASL Profile, Bind 2.0 and Fast for faster reconnects
+* Implement Channel Binding
+* Add ability to switch from audio call to video call
+* Add ability to delete own avatar
+* Add notification for missed calls
+
### Version 2.10.10
* Minor bug fixes
@@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:7.2.2'
+ classpath 'com.android.tools.build:gradle:7.3.1'
}
}
@@ -49,7 +49,7 @@ dependencies {
implementation 'androidx.viewpager:viewpager:1.0.0'
- playstoreImplementation('com.google.firebase:firebase-messaging:23.0.7') {
+ playstoreImplementation('com.google.firebase:firebase-messaging:23.1.0') {
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'
@@ -59,11 +59,11 @@ dependencies {
quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.1'
implementation 'org.sufficientlysecure:openpgp-api:10.0'
implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
- implementation 'androidx.appcompat:appcompat:1.5.0'
- implementation 'androidx.exifinterface:exifinterface:1.3.3'
+ implementation 'androidx.appcompat:appcompat:1.5.1'
+ implementation 'androidx.exifinterface:exifinterface:1.3.5'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
- implementation 'com.google.android.material:material:1.4.0'
+ implementation 'com.google.android.material:material:1.7.0'
implementation "androidx.emoji2:emoji2:1.2.0"
freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0"
@@ -77,7 +77,8 @@ dependencies {
implementation 'org.whispersystems:signal-protocol-android:2.6.2'
implementation 'com.makeramen:roundedimageview:2.3.0'
implementation "com.wefika:flowlayout:0.4.1"
- implementation 'com.otaliastudios:transcoder:0.10.4'
+ //noinspection GradleDependency
+ implementation 'com.otaliastudios:transcoder:0.9.1'
implementation 'org.jxmpp:jxmpp-jid:1.0.3'
implementation 'org.osmdroid:osmdroid-android:6.1.11'
@@ -0,0 +1,5 @@
+* Implement Extensible SASL Profile, Bind 2.0 and Fast for faster reconnects
+* Implement Channel Binding
+* Add ability to switch from audio call to video call
+* Add ability to delete own avatar
+* Add notification for missed calls
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=c9490e938b221daf0094982288e4038deed954a3f12fb54cbf270ddf4e37d879
+distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
@@ -100,7 +100,7 @@ public class MagicCreateActivity extends XmppActivity implements TextWatcher {
account.setOption(Account.OPTION_MAGIC_CREATE, true);
account.setOption(Account.OPTION_FIXED_USERNAME, fixedUsername);
if (this.preAuth != null) {
- account.setKey(Account.PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
+ account.setKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
}
xmppConnectionService.createAccount(account);
}
@@ -100,7 +100,7 @@ public class MagicCreateActivity extends XmppActivity implements TextWatcher {
account.setOption(Account.OPTION_MAGIC_CREATE, true);
account.setOption(Account.OPTION_FIXED_USERNAME, fixedUsername);
if (this.preAuth != null) {
- account.setKey(Account.PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
+ account.setKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN, this.preAuth);
}
xmppConnectionService.createAccount(account);
}
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="pick_a_server">Odaberite svog XMPP davatelja usluga.</string>
+ <string name="use_conversations.im">Koristite conversations.im</string>
+ <string name="create_new_account">Napravi novi račun</string>
+ <string name="do_you_have_an_account">Već imate XMPP račun? To može biti slučaj ako već koristite drugi XMPP klijent ili ste prije koristili Razgovore. Ako niste, možete odmah stvoriti novi XMPP račun.\nSavjet: Neki pružatelji usluga e-pošte također nude XMPP račune.</string>
+ <string name="server_select_text">XMPP je mreža za razmjenu izravnih poruka neovisna o pružatelju usluga. Možete koristiti ovaj klijent s bilo kojim XMPP poslužiteljem koji odaberete.\nMeđutim, radi vaše udobnosti olakšali smo kreiranje računa na conversations.im; pružatelj usluga posebno prilagođen za korištenje s Conversations.</string>
+ <string name="magic_create_text_on_x">Pozvani ste na %1$s. Vodit ćemo vas kroz postupak kreiranja računa.\nPrilikom odabira %1$s pružatelja moći ćete komunicirati s korisnicima drugih pružatelja dajući im svoju punu XMPP adresu.</string>
+ <string name="magic_create_text_fixed">Pozvani ste na %1$s. Korisničko ime je već odabrano za vas. Vodit ćemo vas kroz postupak kreiranja računa.\nMoći ćete komunicirati s korisnicima drugih pružatelja tako da im date svoju punu XMPP adresu.</string>
+ <string name="your_server_invitation">Vaša pozivnica za poslužitelj</string>
+ <string name="improperly_formatted_provisioning">Neispravno formatiran kod za dodjelu</string>
+ <string name="tap_share_button_send_invite">Dodirnite gumb za dijeljenje kako biste svom kontaktu poslali pozivnicu na %1$s.</string>
+ <string name="if_contact_is_nearby_use_qr">Ako je vaš kontakt u blizini, također može skenirati kod u nastavku kako bi prihvatio vašu pozivnicu.</string>
+ <string name="easy_invite_share_text">Pridružite se %1$s i razgovarajte sa mnom: %2$s</string>
+ <string name="share_invite_with">Podijelite pozivnicu s...</string>
+</resources>
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <string name="pick_a_server">选择您的XMPP提供者</string>
- <string name="use_conversations.im">使用conversations.im</string>
+ <string name="pick_a_server">选择您的 XMPP 提供者</string>
+ <string name="use_conversations.im">使用 conversations.im</string>
<string name="create_new_account">创建新账户</string>
- <string name="do_you_have_an_account">您已经拥有一个XMPP账户了吗?如果您之前使用过其他的XMPP客户端的话,那么您已经拥有这种账户了。如果没有账户的话,您可以现在创建一个。\n提示:有些电子邮件服务也提供XMPP账户。</string>
- <string name="server_select_text">XMPP是独立于提供程序的即时消息网络。 您可以将此客户端与所选的任何XMPP服务器一起使用。\ n不过,为了您的方便,我们很容易在对话中创建帐户。im; 特别适合与“对话”配合使用的提供商。</string>
- <string name="magic_create_text_on_x">您已受邀参加%1$s。 我们将指导您完成创建帐户的过程。\n选择%1$s作为提供者后,您可以通过提供其他人的完整XMPP地址与其他提供者的用户进行交流。</string>
- <string name="magic_create_text_fixed">您已受邀参加%1$s。 已经为您选择了一个用户名。 我们将指导您完成创建帐户的过程。\n您可以通过向其他提供商的用户提供完整的XMPP地址来与他们进行交流。</string>
+ <string name="do_you_have_an_account">您有 XMPP 账户吗?如果您之前使用过其他的 XMPP 客户端,那么您已经拥有这种账户了。如果没有的话,您现在可以创建一个。\n提示:有些电子邮件服务也提供XMPP账户。</string>
+ <string name="server_select_text">XMPP 是独立于提供者的即时消息网络。您可以将此客户端与任意 XMPP 服务器一同使用。\n不过,您可以很容易地在 conversations.im 上创建账户;它是特别适合与“Conversations”一起使用的提供者。</string>
+ <string name="magic_create_text_on_x">您已受邀加入 %1$s。我们将指导您完成创建帐户的过程。\n使用 %1$s 作为提供者时,您可以通过您的完整 XMPP 地址与其他提供者的用户进行交流。</string>
+ <string name="magic_create_text_fixed">您已受邀加入 %1$s。已为您选择了一个用户名。我们将指导您完成创建帐户的过程。\n您可以使用完整的 XMPP 地址来与其他提供者的用户进行交流。</string>
<string name="your_server_invitation">你的服务器邀请</string>
<string name="improperly_formatted_provisioning">格式不正确的配置代码</string>
<string name="tap_share_button_send_invite">点击分享按钮向您的联系人发送加入 %1$s 的邀请。</string>
@@ -3,6 +3,14 @@
<string name="pick_a_server">挑選您的 XMPP 提供者</string>
<string name="use_conversations.im">使用 conversations.im</string>
<string name="create_new_account">建立新帳戶</string>
+ <string name="do_you_have_an_account">您已經擁有一個 XMPP 賬戶嗎?如果您之前使用過其他 XMPP 客戶端或 Conversations 的話,那麼您已經擁有 XMPP 賬戶了。如果沒有賬戶的話,您可以現在建立一個。\n提示:有些電子郵件服務供應商也會提供 XMPP 賬戶。</string>
+ <string name="server_select_text">XMPP 是提供者無關的即時訊息網絡。 任何你選擇的 XMPP 伺服器都可在此客戶端上使用。\n不過,我們令它在 Coversations.im 中建立帳戶變得更方便; Conversations.im 是特別適合 Conversations 的提供者</string>
+ <string name="magic_create_text_on_x">你已受邀參加 %1$s 。 我們將指導你完成建立帳戶的過程。選擇 %1$s 作爲提供者後,你可以將你完整的 XMPP 地址交給使用其他提供者的用戶,你便能與他們交流。</string>
+ <string name="magic_create_text_fixed">您已被邀請參加 %1$s 。 我們已經爲你選擇了一個用戶名。 我們將指導你完成建立帳戶的過程。\n將你完整的 XMPP 地址交給使用其他提供者的用戶後,你便能與他們交流。</string>
<string name="your_server_invitation">您的伺服器邀請</string>
- <string name="share_invite_with">分享邀請至…</string>
+ <string name="improperly_formatted_provisioning">配置代碼格式不正確</string>
+ <string name="tap_share_button_send_invite">輕觸分享按鍵向您的聯絡人發送加入 %1$s 的邀請。</string>
+ <string name="if_contact_is_nearby_use_qr">如果你的聯絡人就在附近,他們也可以掃描下面的代碼來接受你的邀請。</string>
+ <string name="easy_invite_share_text">加入 %1$s 和我聊天:%2$s</string>
+ <string name="share_invite_with">分享邀請到...</string>
</resources>
@@ -64,6 +64,9 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="resource/folder" />
</intent>
+ <intent>
+ <action android:name="android.intent.action.VIEW" />
+ </intent>
</queries>
@@ -15,10 +15,9 @@ import eu.siacs.conversations.xmpp.chatstate.ChatState;
public final class Config {
private static final int UNENCRYPTED = 1;
private static final int OPENPGP = 2;
- private static final int OTR = 4;
private static final int OMEMO = 8;
- private static final int ENCRYPTION_MASK = UNENCRYPTED | OPENPGP | OTR | OMEMO;
+ private static final int ENCRYPTION_MASK = UNENCRYPTED | OPENPGP | OMEMO;
public static boolean supportUnencrypted() {
return (ENCRYPTION_MASK & UNENCRYPTED) != 0;
@@ -32,6 +31,10 @@ public final class Config {
return (ENCRYPTION_MASK & OMEMO) != 0;
}
+ public static boolean omemoOnly() {
+ return !multipleEncryptionChoices() && supportOmemo();
+ }
+
public static boolean multipleEncryptionChoices() {
return (ENCRYPTION_MASK & (ENCRYPTION_MASK - 1)) != 0;
}
@@ -57,6 +60,8 @@ public final class Config {
public static final long CONTACT_SYNC_RETRY_INTERVAL = 1000L * 60 * 5;
+ public static final boolean QUICKSTART_ENABLED = true;
+
//Notification settings
public static final boolean HIDE_MESSAGE_TEXT_IN_NOTIFICATION = false;
public static final boolean ALWAYS_NOTIFY_BY_DEFAULT = false;
@@ -210,5 +215,5 @@ public final class Config {
// How deep nested quotes should be displayed. '2' means one quote nested in another.
public static final int QUOTE_MAX_DEPTH = 7;
// How deep nested quotes should be created on quoting a message.
- public static final int QUOTING_MAX_DEPTH = 1;
+ public static final int QUOTING_MAX_DEPTH = 2;
}
@@ -34,6 +34,9 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
+import com.google.common.base.Strings;
+
+import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.ui.SettingsActivity;
@@ -52,8 +55,13 @@ public class OmemoSetting {
}
public static void load(final Context context, final SharedPreferences sharedPreferences) {
+ if (Config.omemoOnly()) {
+ always = true;
+ encryption = Message.ENCRYPTION_AXOLOTL;
+ return;
+ }
final String value = sharedPreferences.getString(SettingsActivity.OMEMO_SETTING, context.getResources().getString(R.string.omemo_setting_default));
- switch (value) {
+ switch (Strings.nullToEmpty(value)) {
case "always":
always = true;
encryption = Message.ENCRYPTION_AXOLOTL;
@@ -1,5 +1,7 @@
package eu.siacs.conversations.crypto.axolotl;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
import android.os.Bundle;
import android.security.KeyChain;
import android.util.Log;
@@ -499,7 +501,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
PrivateKey x509PrivateKey = KeyChain.getPrivateKey(mXmppConnectionService, account.getPrivateKeyAlias());
X509Certificate[] chain = KeyChain.getCertificateChain(mXmppConnectionService, account.getPrivateKeyAlias());
Signature verifier = Signature.getInstance("sha256WithRSA");
- verifier.initSign(x509PrivateKey, mXmppConnectionService.getRNG());
+ verifier.initSign(x509PrivateKey, SECURE_RANDOM);
verifier.update(axolotlPublicKey.serialize());
byte[] signature = verifier.sign();
IqPacket packet = mXmppConnectionService.getIqGenerator().publishVerification(signature, chain, getOwnDeviceId());
@@ -708,11 +710,11 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
}
public void deleteOmemoIdentity() {
- final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId();
- final IqPacket deleteBundleNode = mXmppConnectionService.getIqGenerator().deleteNode(node);
- mXmppConnectionService.sendIqPacket(account, deleteBundleNode, null);
+ mXmppConnectionService.deletePepNode(
+ account, AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId());
final Set<Integer> ownDeviceIds = getOwnDeviceIds();
- publishDeviceIdsAndRefineAccessModel(ownDeviceIds == null ? Collections.emptySet() : ownDeviceIds);
+ publishDeviceIdsAndRefineAccessModel(
+ ownDeviceIds == null ? Collections.emptySet() : ownDeviceIds);
}
public List<Jid> getCryptoTargets(Conversation conversation) {
@@ -1270,7 +1272,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
}
descriptionTransportBuilder.put(
content.getKey(),
- new RtpContentMap.DescriptionTransport(descriptionTransport.description, encryptedTransportInfo)
+ new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo)
);
}
return Futures.immediateFuture(
@@ -1304,7 +1306,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
omemoVerification.setOrEnsureEqual(decryptedTransport);
descriptionTransportBuilder.put(
content.getKey(),
- new RtpContentMap.DescriptionTransport(descriptionTransport.description, decryptedTransport.payload)
+ new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload)
);
}
processPostponed();
@@ -1,16 +1,15 @@
package eu.siacs.conversations.crypto.sasl;
-import java.security.SecureRandom;
+import javax.net.ssl.SSLSocket;
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
public class Anonymous extends SaslMechanism {
public static final String MECHANISM = "ANONYMOUS";
- public Anonymous(TagWriter tagWriter, Account account, SecureRandom rng) {
- super(tagWriter, account, rng);
+ public Anonymous(final Account account) {
+ super(account);
}
@Override
@@ -24,7 +23,7 @@ public class Anonymous extends SaslMechanism {
}
@Override
- public String getClientFirstMessage() {
+ public String getClientFirstMessage(final SSLSocket sslSocket) {
return "";
}
}
@@ -0,0 +1,120 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import android.util.Log;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Predicates;
+import com.google.common.base.Strings;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableBiMap;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.SSLSockets;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+
+public enum ChannelBinding {
+ NONE,
+ TLS_EXPORTER,
+ TLS_SERVER_END_POINT,
+ TLS_UNIQUE;
+
+ public static final BiMap<ChannelBinding, String> SHORT_NAMES;
+
+ static {
+ final ImmutableBiMap.Builder<ChannelBinding, String> builder = ImmutableBiMap.builder();
+ for (final ChannelBinding cb : values()) {
+ builder.put(cb, shortName(cb));
+ }
+ SHORT_NAMES = builder.build();
+ }
+
+ public static Collection<ChannelBinding> of(final Element channelBinding) {
+ Preconditions.checkArgument(
+ channelBinding == null
+ || ("sasl-channel-binding".equals(channelBinding.getName())
+ && Namespace.CHANNEL_BINDING.equals(channelBinding.getNamespace())),
+ "pass null or a valid channel binding stream feature");
+ return Collections2.filter(
+ Collections2.transform(
+ Collections2.filter(
+ channelBinding == null
+ ? Collections.emptyList()
+ : channelBinding.getChildren(),
+ c -> c != null && "channel-binding".equals(c.getName())),
+ c -> c == null ? null : ChannelBinding.of(c.getAttribute("type"))),
+ Predicates.notNull());
+ }
+
+ private static ChannelBinding of(final String type) {
+ if (type == null) {
+ return null;
+ }
+ try {
+ return valueOf(
+ CaseFormat.LOWER_HYPHEN.converterTo(CaseFormat.UPPER_UNDERSCORE).convert(type));
+ } catch (final IllegalArgumentException e) {
+ Log.d(Config.LOGTAG, type + " is not a known channel binding");
+ return null;
+ }
+ }
+
+ public static ChannelBinding get(final String name) {
+ if (Strings.isNullOrEmpty(name)) {
+ return NONE;
+ }
+ try {
+ return valueOf(name);
+ } catch (final IllegalArgumentException e) {
+ return NONE;
+ }
+ }
+
+ public static ChannelBinding best(
+ final Collection<ChannelBinding> bindings, final SSLSockets.Version sslVersion) {
+ if (sslVersion == SSLSockets.Version.NONE) {
+ return NONE;
+ }
+ if (bindings.contains(TLS_EXPORTER) && sslVersion == SSLSockets.Version.TLS_1_3) {
+ return TLS_EXPORTER;
+ } else if (bindings.contains(TLS_UNIQUE)
+ && Arrays.asList(
+ SSLSockets.Version.TLS_1_0,
+ SSLSockets.Version.TLS_1_1,
+ SSLSockets.Version.TLS_1_2)
+ .contains(sslVersion)) {
+ return TLS_UNIQUE;
+ } else if (bindings.contains(TLS_SERVER_END_POINT)) {
+ return TLS_SERVER_END_POINT;
+ } else {
+ return NONE;
+ }
+ }
+
+ public static boolean isAvailable(
+ final ChannelBinding channelBinding, final SSLSockets.Version sslVersion) {
+ return ChannelBinding.best(Collections.singleton(channelBinding), sslVersion)
+ == channelBinding;
+ }
+
+ private static String shortName(final ChannelBinding channelBinding) {
+ switch (channelBinding) {
+ case TLS_UNIQUE:
+ return "UNIQ";
+ case TLS_EXPORTER:
+ return "EXPR";
+ case TLS_SERVER_END_POINT:
+ return "ENDP";
+ case NONE:
+ return "NONE";
+ default:
+ throw new AssertionError("Missing short name for " + channelBinding);
+ }
+ }
+}
@@ -0,0 +1,100 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import org.bouncycastle.jcajce.provider.digest.SHA256;
+import org.conscrypt.Conscrypt;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocket;
+
+public interface ChannelBindingMechanism {
+
+ String EXPORTER_LABEL = "EXPORTER-Channel-Binding";
+
+ ChannelBinding getChannelBinding();
+
+ static byte[] getChannelBindingData(final SSLSocket sslSocket, final ChannelBinding channelBinding)
+ throws SaslMechanism.AuthenticationException {
+ if (sslSocket == null) {
+ throw new SaslMechanism.AuthenticationException("Channel binding attempt on non secure socket");
+ }
+ if (channelBinding == ChannelBinding.TLS_EXPORTER) {
+ final byte[] keyingMaterial;
+ try {
+ keyingMaterial =
+ Conscrypt.exportKeyingMaterial(sslSocket, EXPORTER_LABEL, new byte[0], 32);
+ } catch (final SSLException e) {
+ throw new SaslMechanism.AuthenticationException("Could not export keying material");
+ }
+ if (keyingMaterial == null) {
+ throw new SaslMechanism.AuthenticationException(
+ "Could not export keying material. Socket not ready");
+ }
+ return keyingMaterial;
+ } else if (channelBinding == ChannelBinding.TLS_UNIQUE) {
+ final byte[] unique = Conscrypt.getTlsUnique(sslSocket);
+ if (unique == null) {
+ throw new SaslMechanism.AuthenticationException(
+ "Could not retrieve tls unique. Socket not ready");
+ }
+ return unique;
+ } else if (channelBinding == ChannelBinding.TLS_SERVER_END_POINT) {
+ return getServerEndPointChannelBinding(sslSocket.getSession());
+ } else {
+ throw new SaslMechanism.AuthenticationException(
+ String.format("%s is not a valid channel binding", channelBinding));
+ }
+ }
+
+ static byte[] getServerEndPointChannelBinding(final SSLSession session)
+ throws SaslMechanism.AuthenticationException {
+ final Certificate[] certificates;
+ try {
+ certificates = session.getPeerCertificates();
+ } catch (final SSLPeerUnverifiedException e) {
+ throw new SaslMechanism.AuthenticationException("Could not verify peer certificates");
+ }
+ if (certificates == null || certificates.length == 0) {
+ throw new SaslMechanism.AuthenticationException("Could not retrieve peer certificate");
+ }
+ final X509Certificate certificate;
+ if (certificates[0] instanceof X509Certificate) {
+ certificate = (X509Certificate) certificates[0];
+ } else {
+ throw new SaslMechanism.AuthenticationException("Certificate was not X509");
+ }
+ final String algorithm = certificate.getSigAlgName();
+ final int withIndex = algorithm.indexOf("with");
+ if (withIndex <= 0) {
+ throw new SaslMechanism.AuthenticationException("Unable to parse SigAlgName");
+ }
+ final String hashAlgorithm = algorithm.substring(0, withIndex);
+ final MessageDigest messageDigest;
+ // https://www.rfc-editor.org/rfc/rfc5929#section-4.1
+ if ("MD5".equalsIgnoreCase(hashAlgorithm) || "SHA1".equalsIgnoreCase(hashAlgorithm)) {
+ messageDigest = new SHA256.Digest();
+ } else {
+ try {
+ messageDigest = MessageDigest.getInstance(hashAlgorithm);
+ } catch (final NoSuchAlgorithmException e) {
+ throw new SaslMechanism.AuthenticationException(
+ "Could not instantiate message digest for " + hashAlgorithm);
+ }
+ }
+ final byte[] encodedCertificate;
+ try {
+ encodedCertificate = certificate.getEncoded();
+ } catch (final CertificateEncodingException e) {
+ throw new SaslMechanism.AuthenticationException("Could not encode certificate");
+ }
+ messageDigest.update(encodedCertificate);
+ return messageDigest.digest();
+ }
+}
@@ -5,18 +5,19 @@ import android.util.Base64;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
+
+import javax.net.ssl.SSLSocket;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.xml.TagWriter;
public class DigestMd5 extends SaslMechanism {
public static final String MECHANISM = "DIGEST-MD5";
+ private State state = State.INITIAL;
- public DigestMd5(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
- super(tagWriter, account, rng);
+ public DigestMd5(final Account account) {
+ super(account);
}
@Override
@@ -29,16 +30,16 @@ public class DigestMd5 extends SaslMechanism {
return MECHANISM;
}
- private State state = State.INITIAL;
-
@Override
- public String getResponse(final String challenge) throws AuthenticationException {
+ public String getResponse(final String challenge, final SSLSocket sslSocket)
+ throws AuthenticationException {
switch (state) {
case INITIAL:
state = State.RESPONSE_SENT;
final String encodedResponse;
try {
- final Tokenizer tokenizer = new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
+ final Tokenizer tokenizer =
+ new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
String nonce = "";
for (final String token : tokenizer) {
final String[] parts = token.split("=", 2);
@@ -50,29 +51,49 @@ public class DigestMd5 extends SaslMechanism {
}
final String digestUri = "xmpp/" + account.getServer();
final String nonceCount = "00000001";
- final String x = account.getUsername() + ":" + account.getServer() + ":"
- + account.getPassword();
+ final String x =
+ account.getUsername()
+ + ":"
+ + account.getServer()
+ + ":"
+ + account.getPassword();
final MessageDigest md = MessageDigest.getInstance("MD5");
final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
- final String cNonce = CryptoHelper.random(100, rng);
- final byte[] a1 = CryptoHelper.concatenateByteArrays(y,
- (":" + nonce + ":" + cNonce).getBytes(Charset.defaultCharset()));
+ final String cNonce = CryptoHelper.random(100);
+ final byte[] a1 =
+ CryptoHelper.concatenateByteArrays(
+ y,
+ (":" + nonce + ":" + cNonce)
+ .getBytes(Charset.defaultCharset()));
final String a2 = "AUTHENTICATE:" + digestUri;
final String ha1 = CryptoHelper.bytesToHex(md.digest(a1));
- final String ha2 = CryptoHelper.bytesToHex(md.digest(a2.getBytes(Charset
- .defaultCharset())));
- final String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce
- + ":auth:" + ha2;
- final String response = CryptoHelper.bytesToHex(md.digest(kd.getBytes(Charset
- .defaultCharset())));
- final String saslString = "username=\"" + account.getUsername()
- + "\",realm=\"" + account.getServer() + "\",nonce=\""
- + nonce + "\",cnonce=\"" + cNonce + "\",nc=" + nonceCount
- + ",qop=auth,digest-uri=\"" + digestUri + "\",response="
- + response + ",charset=utf-8";
- encodedResponse = Base64.encodeToString(
- saslString.getBytes(Charset.defaultCharset()),
- Base64.NO_WRAP);
+ final String ha2 =
+ CryptoHelper.bytesToHex(
+ md.digest(a2.getBytes(Charset.defaultCharset())));
+ final String kd =
+ ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2;
+ final String response =
+ CryptoHelper.bytesToHex(
+ md.digest(kd.getBytes(Charset.defaultCharset())));
+ final String saslString =
+ "username=\""
+ + account.getUsername()
+ + "\",realm=\""
+ + account.getServer()
+ + "\",nonce=\""
+ + nonce
+ + "\",cnonce=\""
+ + cNonce
+ + "\",nc="
+ + nonceCount
+ + ",qop=auth,digest-uri=\""
+ + digestUri
+ + "\",response="
+ + response
+ + ",charset=utf-8";
+ encodedResponse =
+ Base64.encodeToString(
+ saslString.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
} catch (final NoSuchAlgorithmException e) {
throw new AuthenticationException(e);
}
@@ -83,7 +104,7 @@ public class DigestMd5 extends SaslMechanism {
break;
case VALID_SERVER_RESPONSE:
if (challenge == null) {
- return null; //everything is fine
+ return null; // everything is fine
}
default:
throw new InvalidStateException(state);
@@ -2,17 +2,16 @@ package eu.siacs.conversations.crypto.sasl;
import android.util.Base64;
-import java.security.SecureRandom;
+import javax.net.ssl.SSLSocket;
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
public class External extends SaslMechanism {
public static final String MECHANISM = "EXTERNAL";
- public External(TagWriter tagWriter, Account account, SecureRandom rng) {
- super(tagWriter, account, rng);
+ public External(final Account account) {
+ super(account);
}
@Override
@@ -26,7 +25,8 @@ public class External extends SaslMechanism {
}
@Override
- public String getClientFirstMessage() {
- return Base64.encodeToString(account.getJid().asBareJid().toEscapedString().getBytes(), Base64.NO_WRAP);
+ public String getClientFirstMessage(final SSLSocket sslSocket) {
+ return Base64.encodeToString(
+ account.getJid().asBareJid().toEscapedString().getBytes(), Base64.NO_WRAP);
}
}
@@ -0,0 +1,190 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import android.util.Base64;
+import android.util.Log;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.hash.HashFunction;
+import com.google.common.primitives.Bytes;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.utils.SSLSockets;
+
+public abstract class HashedToken extends SaslMechanism implements ChannelBindingMechanism {
+
+ private static final String PREFIX = "HT";
+
+ private static final List<String> HASH_FUNCTIONS = Arrays.asList("SHA-512", "SHA-256");
+ private static final byte[] INITIATOR = "Initiator".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] RESPONDER = "Responder".getBytes(StandardCharsets.UTF_8);
+
+ protected final ChannelBinding channelBinding;
+
+ protected HashedToken(final Account account, final ChannelBinding channelBinding) {
+ super(account);
+ this.channelBinding = channelBinding;
+ }
+
+ @Override
+ public int getPriority() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getClientFirstMessage(final SSLSocket sslSocket) {
+ final String token = Strings.nullToEmpty(this.account.getFastToken());
+ final HashFunction hashing = getHashFunction(token.getBytes(StandardCharsets.UTF_8));
+ final byte[] cbData = getChannelBindingData(sslSocket);
+ final byte[] initiatorHashedToken =
+ hashing.hashBytes(Bytes.concat(INITIATOR, cbData)).asBytes();
+ final byte[] firstMessage =
+ Bytes.concat(
+ account.getUsername().getBytes(StandardCharsets.UTF_8),
+ new byte[] {0x00},
+ initiatorHashedToken);
+ return Base64.encodeToString(firstMessage, Base64.NO_WRAP);
+ }
+
+ private byte[] getChannelBindingData(final SSLSocket sslSocket) {
+ if (this.channelBinding == ChannelBinding.NONE) {
+ return new byte[0];
+ }
+ try {
+ return ChannelBindingMechanism.getChannelBindingData(sslSocket, this.channelBinding);
+ } catch (final AuthenticationException e) {
+ Log.e(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": unable to retrieve channel binding data for "
+ + getMechanism(),
+ e);
+ return new byte[0];
+ }
+ }
+
+ @Override
+ public String getResponse(final String challenge, final SSLSocket socket)
+ throws AuthenticationException {
+ final byte[] responderMessage;
+ try {
+ responderMessage = Base64.decode(challenge, Base64.NO_WRAP);
+ } catch (final Exception e) {
+ throw new AuthenticationException("Unable to decode responder message", e);
+ }
+ final String token = Strings.nullToEmpty(this.account.getFastToken());
+ final HashFunction hashing = getHashFunction(token.getBytes(StandardCharsets.UTF_8));
+ final byte[] cbData = getChannelBindingData(socket);
+ final byte[] expectedResponderMessage =
+ hashing.hashBytes(Bytes.concat(RESPONDER, cbData)).asBytes();
+ if (Arrays.equals(responderMessage, expectedResponderMessage)) {
+ return null;
+ }
+ throw new AuthenticationException("Responder message did not match");
+ }
+
+ protected abstract HashFunction getHashFunction(final byte[] key);
+
+ public abstract Mechanism getTokenMechanism();
+
+ @Override
+ public String getMechanism() {
+ return getTokenMechanism().name();
+ }
+
+ public static final class Mechanism {
+ public final String hashFunction;
+ public final ChannelBinding channelBinding;
+
+ public Mechanism(String hashFunction, ChannelBinding channelBinding) {
+ this.hashFunction = hashFunction;
+ this.channelBinding = channelBinding;
+ }
+
+ public static Mechanism of(final String mechanism) {
+ final int first = mechanism.indexOf('-');
+ final int last = mechanism.lastIndexOf('-');
+ if (last <= first || mechanism.length() <= last) {
+ throw new IllegalArgumentException("Not a valid HashedToken name");
+ }
+ if (mechanism.substring(0, first).equals(PREFIX)) {
+ final String hashFunction = mechanism.substring(first + 1, last);
+ final String cbShortName = mechanism.substring(last + 1);
+ final ChannelBinding channelBinding =
+ ChannelBinding.SHORT_NAMES.inverse().get(cbShortName);
+ if (channelBinding == null) {
+ throw new IllegalArgumentException("Unknown channel binding " + cbShortName);
+ }
+ return new Mechanism(hashFunction, channelBinding);
+ } else {
+ throw new IllegalArgumentException("HashedToken name does not start with HT");
+ }
+ }
+
+ public static Mechanism ofOrNull(final String mechanism) {
+ try {
+ return mechanism == null ? null : of(mechanism);
+ } catch (final IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ public static Multimap<String, ChannelBinding> of(final Collection<String> mechanisms) {
+ final ImmutableMultimap.Builder<String, ChannelBinding> builder =
+ ImmutableMultimap.builder();
+ for (final String name : mechanisms) {
+ try {
+ final Mechanism mechanism = Mechanism.of(name);
+ builder.put(mechanism.hashFunction, mechanism.channelBinding);
+ } catch (final IllegalArgumentException ignored) {
+ }
+ }
+ return builder.build();
+ }
+
+ public static Mechanism best(
+ final Collection<String> mechanisms, final SSLSockets.Version sslVersion) {
+ final Multimap<String, ChannelBinding> multimap = of(mechanisms);
+ for (final String hashFunction : HASH_FUNCTIONS) {
+ final Collection<ChannelBinding> channelBindings = multimap.get(hashFunction);
+ if (channelBindings.isEmpty()) {
+ continue;
+ }
+ final ChannelBinding cb = ChannelBinding.best(channelBindings, sslVersion);
+ return new Mechanism(hashFunction, cb);
+ }
+ return null;
+ }
+
+ @NotNull
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("hashFunction", hashFunction)
+ .add("channelBinding", channelBinding)
+ .toString();
+ }
+
+ public String name() {
+ return String.format(
+ "%s-%s-%s",
+ PREFIX, hashFunction, ChannelBinding.SHORT_NAMES.get(channelBinding));
+ }
+ }
+
+ public ChannelBinding getChannelBinding() {
+ return this.channelBinding;
+ }
+}
@@ -0,0 +1,23 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class HashedTokenSha256 extends HashedToken {
+
+ public HashedTokenSha256(final Account account, final ChannelBinding channelBinding) {
+ super(account, channelBinding);
+ }
+
+ @Override
+ protected HashFunction getHashFunction(final byte[] key) {
+ return Hashing.hmacSha256(key);
+ }
+
+ @Override
+ public Mechanism getTokenMechanism() {
+ return new Mechanism("SHA-256", channelBinding);
+ }
+}
@@ -0,0 +1,23 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class HashedTokenSha512 extends HashedToken {
+
+ public HashedTokenSha512(final Account account, final ChannelBinding channelBinding) {
+ super(account, channelBinding);
+ }
+
+ @Override
+ protected HashFunction getHashFunction(final byte[] key) {
+ return Hashing.hmacSha512(key);
+ }
+
+ @Override
+ public Mechanism getTokenMechanism() {
+ return new Mechanism("SHA-512", this.channelBinding);
+ }
+}
@@ -4,15 +4,21 @@ import android.util.Base64;
import java.nio.charset.Charset;
+import javax.net.ssl.SSLSocket;
+
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
public class Plain extends SaslMechanism {
public static final String MECHANISM = "PLAIN";
- public Plain(final TagWriter tagWriter, final Account account) {
- super(tagWriter, account, null);
+ public Plain(final Account account) {
+ super(account);
+ }
+
+ public static String getMessage(String username, String password) {
+ final String message = '\u0000' + username + '\u0000' + password;
+ return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
}
@Override
@@ -26,12 +32,7 @@ public class Plain extends SaslMechanism {
}
@Override
- public String getClientFirstMessage() {
+ public String getClientFirstMessage(final SSLSocket sslSocket) {
return getMessage(account.getUsername(), account.getPassword());
}
-
- public static String getMessage(String username, String password) {
- final String message = '\u0000' + username + '\u0000' + password;
- return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
- }
}
@@ -1,15 +1,68 @@
package eu.siacs.conversations.crypto.sasl;
-import java.security.SecureRandom;
+import android.util.Log;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
+import eu.siacs.conversations.utils.SSLSockets;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
public abstract class SaslMechanism {
- final protected TagWriter tagWriter;
- final protected Account account;
- final protected SecureRandom rng;
+ protected final Account account;
+
+ protected SaslMechanism(final Account account) {
+ this.account = account;
+ }
+
+ public static String namespace(final Version version) {
+ if (version == Version.SASL) {
+ return Namespace.SASL;
+ } else {
+ return Namespace.SASL_2;
+ }
+ }
+
+ /**
+ * The priority is used to pin the authentication mechanism. If authentication fails, it MAY be
+ * retried with another mechanism of the same priority, but MUST NOT be tried with a mechanism
+ * of lower priority (to prevent downgrade attacks).
+ *
+ * @return An arbitrary int representing the priority
+ */
+ public abstract int getPriority();
+
+ public abstract String getMechanism();
+
+ public String getClientFirstMessage(final SSLSocket sslSocket) {
+ return "";
+ }
+
+ public String getResponse(final String challenge, final SSLSocket sslSocket)
+ throws AuthenticationException {
+ return "";
+ }
+
+ public static Collection<String> mechanisms(final Element authElement) {
+ if (authElement == null) {
+ return Collections.emptyList();
+ }
+ return Collections2.transform(
+ Collections2.filter(
+ authElement.getChildren(),
+ c -> c != null && "mechanism".equals(c.getName())),
+ c -> c == null ? null : c.getContent());
+ }
protected enum State {
INITIAL,
@@ -18,6 +71,22 @@ public abstract class SaslMechanism {
VALID_SERVER_RESPONSE,
}
+ public enum Version {
+ SASL,
+ SASL_2;
+
+ public static Version of(final Element element) {
+ switch (Strings.nullToEmpty(element.getNamespace())) {
+ case Namespace.SASL:
+ return SASL;
+ case Namespace.SASL_2:
+ return SASL_2;
+ default:
+ throw new IllegalArgumentException("Unrecognized SASL namespace");
+ }
+ }
+ }
+
public static class AuthenticationException extends Exception {
public AuthenticationException(final String message) {
super(message);
@@ -42,28 +111,86 @@ public abstract class SaslMechanism {
}
}
- public SaslMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
- this.tagWriter = tagWriter;
- this.account = account;
- this.rng = rng;
- }
+ public static final class Factory {
- /**
- * The priority is used to pin the authentication mechanism. If authentication fails, it MAY be retried with another
- * mechanism of the same priority, but MUST NOT be tried with a mechanism of lower priority (to prevent downgrade
- * attacks).
- *
- * @return An arbitrary int representing the priority
- */
- public abstract int getPriority();
+ private final Account account;
- public abstract String getMechanism();
+ public Factory(final Account account) {
+ this.account = account;
+ }
- public String getClientFirstMessage() {
- return "";
+ private SaslMechanism of(
+ final Collection<String> mechanisms, final ChannelBinding channelBinding) {
+ Preconditions.checkNotNull(channelBinding, "Use ChannelBinding.NONE instead of null");
+ if (mechanisms.contains(External.MECHANISM) && account.getPrivateKeyAlias() != null) {
+ return new External(account);
+ } else if (mechanisms.contains(ScramSha512Plus.MECHANISM)
+ && channelBinding != ChannelBinding.NONE) {
+ return new ScramSha512Plus(account, channelBinding);
+ } else if (mechanisms.contains(ScramSha256Plus.MECHANISM)
+ && channelBinding != ChannelBinding.NONE) {
+ return new ScramSha256Plus(account, channelBinding);
+ } else if (mechanisms.contains(ScramSha1Plus.MECHANISM)
+ && channelBinding != ChannelBinding.NONE) {
+ return new ScramSha1Plus(account, channelBinding);
+ } else if (mechanisms.contains(ScramSha512.MECHANISM)) {
+ return new ScramSha512(account);
+ } else if (mechanisms.contains(ScramSha256.MECHANISM)) {
+ return new ScramSha256(account);
+ } else if (mechanisms.contains(ScramSha1.MECHANISM)) {
+ return new ScramSha1(account);
+ } else if (mechanisms.contains(Plain.MECHANISM)
+ && !account.getServer().equals("nimbuzz.com")) {
+ return new Plain(account);
+ } else if (mechanisms.contains(DigestMd5.MECHANISM)) {
+ return new DigestMd5(account);
+ } else if (mechanisms.contains(Anonymous.MECHANISM)) {
+ return new Anonymous(account);
+ } else {
+ return null;
+ }
+ }
+
+ public SaslMechanism of(
+ final Collection<String> mechanisms,
+ final Collection<ChannelBinding> bindings,
+ final Version version,
+ final SSLSockets.Version sslVersion) {
+ final HashedToken fastMechanism = account.getFastMechanism();
+ if (version == Version.SASL_2 && fastMechanism != null) {
+ return fastMechanism;
+ }
+ final ChannelBinding channelBinding = ChannelBinding.best(bindings, sslVersion);
+ return of(mechanisms, channelBinding);
+ }
+
+ public SaslMechanism of(final String mechanism, final ChannelBinding channelBinding) {
+ return of(Collections.singleton(mechanism), channelBinding);
+ }
}
- public String getResponse(final String challenge) throws AuthenticationException {
- return "";
+ public static SaslMechanism ensureAvailable(
+ final SaslMechanism mechanism, final SSLSockets.Version sslVersion) {
+ if (mechanism instanceof ChannelBindingMechanism) {
+ final ChannelBinding cb = ((ChannelBindingMechanism) mechanism).getChannelBinding();
+ if (ChannelBinding.isAvailable(cb, sslVersion)) {
+ return mechanism;
+ } else {
+ Log.d(
+ Config.LOGTAG,
+ "pinned channel binding method " + cb + " no longer available");
+ return null;
+ }
+ } else {
+ return mechanism;
+ }
+ }
+
+ public static boolean hashedToken(final SaslMechanism saslMechanism) {
+ return saslMechanism instanceof HashedToken;
+ }
+
+ public static boolean pin(final SaslMechanism saslMechanism) {
+ return !hashedToken(saslMechanism);
}
}
@@ -1,105 +1,85 @@
package eu.siacs.conversations.crypto.sasl;
import android.util.Base64;
+import android.util.Log;
+import com.google.common.base.CaseFormat;
import com.google.common.base.Objects;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
-
-import org.bouncycastle.crypto.Digest;
-import org.bouncycastle.crypto.macs.HMac;
-import org.bouncycastle.crypto.params.KeyParameter;
+import com.google.common.hash.HashFunction;
import java.nio.charset.Charset;
import java.security.InvalidKeyException;
-import java.security.SecureRandom;
import java.util.concurrent.ExecutionException;
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.xml.TagWriter;
abstract class ScramMechanism extends SaslMechanism {
- // TODO: When channel binding (SCRAM-SHA1-PLUS) is supported in future, generalize this to indicate support and/or usage.
- private final static String GS2_HEADER = "n,,";
+
private static final byte[] CLIENT_KEY_BYTES = "Client Key".getBytes();
private static final byte[] SERVER_KEY_BYTES = "Server Key".getBytes();
-
- protected abstract HMac getHMAC();
-
- protected abstract Digest getDigest();
-
- private static final Cache<CacheKey, KeyPair> CACHE = CacheBuilder.newBuilder().maximumSize(10).build();
-
- private static class CacheKey {
- final String algorithm;
- final String password;
- final String salt;
- final int iterations;
-
- private CacheKey(String algorithm, String password, String salt, int iterations) {
- this.algorithm = algorithm;
- this.password = password;
- this.salt = salt;
- this.iterations = iterations;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
- CacheKey cacheKey = (CacheKey) o;
- return iterations == cacheKey.iterations &&
- Objects.equal(algorithm, cacheKey.algorithm) &&
- Objects.equal(password, cacheKey.password) &&
- Objects.equal(salt, cacheKey.salt);
- }
-
- @Override
- public int hashCode() {
- return Objects.hashCode(algorithm, password, salt, iterations);
- }
- }
-
- private KeyPair getKeyPair(final String password, final String salt, final int iterations) throws ExecutionException {
- return CACHE.get(new CacheKey(getHMAC().getAlgorithmName(), password, salt, iterations), () -> {
- final byte[] saltedPassword, serverKey, clientKey;
- saltedPassword = hi(password.getBytes(), Base64.decode(salt, Base64.DEFAULT), iterations);
- serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
- clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
- return new KeyPair(clientKey, serverKey);
- });
- }
-
+ private static final Cache<CacheKey, KeyPair> CACHE =
+ CacheBuilder.newBuilder().maximumSize(10).build();
+ protected final ChannelBinding channelBinding;
+ private final String gs2Header;
private final String clientNonce;
protected State state = State.INITIAL;
private String clientFirstMessageBare;
private byte[] serverSignature = null;
- ScramMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
- super(tagWriter, account, rng);
-
+ ScramMechanism(final Account account, final ChannelBinding channelBinding) {
+ super(account);
+ this.channelBinding = channelBinding;
+ if (channelBinding == ChannelBinding.NONE) {
+ // TODO this needs to be changed to "y,," for the scram internal down grade protection
+ // but we might risk compatibility issues if the server supports a binding that we don’t
+ // support
+ this.gs2Header = "n,,";
+ } else {
+ this.gs2Header =
+ String.format(
+ "p=%s,,",
+ CaseFormat.UPPER_UNDERSCORE
+ .converterTo(CaseFormat.LOWER_HYPHEN)
+ .convert(channelBinding.toString()));
+ }
// This nonce should be different for each authentication attempt.
- clientNonce = CryptoHelper.random(100, rng);
+ this.clientNonce = CryptoHelper.random(100);
clientFirstMessageBare = "";
}
+ protected abstract HashFunction getHMac(final byte[] key);
+
+ protected abstract HashFunction getDigest();
+
+ private KeyPair getKeyPair(final String password, final String salt, final int iterations)
+ throws ExecutionException {
+ return CACHE.get(
+ new CacheKey(getMechanism(), password, salt, iterations),
+ () -> {
+ final byte[] saltedPassword, serverKey, clientKey;
+ saltedPassword =
+ hi(
+ password.getBytes(),
+ Base64.decode(salt, Base64.DEFAULT),
+ iterations);
+ serverKey = hmac(saltedPassword, SERVER_KEY_BYTES);
+ clientKey = hmac(saltedPassword, CLIENT_KEY_BYTES);
+ return new KeyPair(clientKey, serverKey);
+ });
+ }
+
private byte[] hmac(final byte[] key, final byte[] input) throws InvalidKeyException {
- final HMac hMac = getHMAC();
- hMac.init(new KeyParameter(key));
- hMac.update(input, 0, input.length);
- final byte[] out = new byte[hMac.getMacSize()];
- hMac.doFinal(out, 0);
- return out;
+ return getHMac(key).hashBytes(input).asBytes();
}
- public byte[] digest(byte[] bytes) {
- final Digest digest = getDigest();
- digest.reset();
- digest.update(bytes, 0, bytes.length);
- final byte[] out = new byte[digest.getDigestSize()];
- digest.doFinal(out, 0);
- return out;
+ private byte[] digest(final byte[] bytes) {
+ return getDigest().hashBytes(bytes).asBytes();
}
/*
@@ -121,19 +101,23 @@ abstract class ScramMechanism extends SaslMechanism {
}
@Override
- public String getClientFirstMessage() {
+ public String getClientFirstMessage(final SSLSocket sslSocket) {
if (clientFirstMessageBare.isEmpty() && state == State.INITIAL) {
- clientFirstMessageBare = "n=" + CryptoHelper.saslEscape(CryptoHelper.saslPrep(account.getUsername())) +
- ",r=" + this.clientNonce;
+ clientFirstMessageBare =
+ "n="
+ + CryptoHelper.saslEscape(CryptoHelper.saslPrep(account.getUsername()))
+ + ",r="
+ + this.clientNonce;
state = State.AUTH_TEXT_SENT;
}
return Base64.encodeToString(
- (GS2_HEADER + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
+ (gs2Header + clientFirstMessageBare).getBytes(Charset.defaultCharset()),
Base64.NO_WRAP);
}
@Override
- public String getResponse(final String challenge) throws AuthenticationException {
+ public String getResponse(final String challenge, final SSLSocket socket)
+ throws AuthenticationException {
switch (state) {
case AUTH_TEXT_SENT:
if (challenge == null) {
@@ -173,7 +157,8 @@ abstract class ScramMechanism extends SaslMechanism {
* MUST cause authentication failure when the attribute is parsed by
* the other end.
*/
- throw new AuthenticationException("Server sent reserved token: `m'");
+ throw new AuthenticationException(
+ "Server sent reserved token: `m'");
}
}
}
@@ -182,20 +167,39 @@ abstract class ScramMechanism extends SaslMechanism {
throw new AuthenticationException("Server did not send iteration count");
}
if (nonce.isEmpty() || !nonce.startsWith(clientNonce)) {
- throw new AuthenticationException("Server nonce does not contain client nonce: " + nonce);
+ throw new AuthenticationException(
+ "Server nonce does not contain client nonce: " + nonce);
}
if (salt.isEmpty()) {
throw new AuthenticationException("Server sent empty salt");
}
- final String clientFinalMessageWithoutProof = "c=" + Base64.encodeToString(
- GS2_HEADER.getBytes(), Base64.NO_WRAP) + ",r=" + nonce;
- final byte[] authMessage = (clientFirstMessageBare + ',' + new String(serverFirstMessage) + ','
- + clientFinalMessageWithoutProof).getBytes();
+ final byte[] channelBindingData = getChannelBindingData(socket);
+
+ final int gs2Len = this.gs2Header.getBytes().length;
+ final byte[] cMessage = new byte[gs2Len + channelBindingData.length];
+ System.arraycopy(this.gs2Header.getBytes(), 0, cMessage, 0, gs2Len);
+ System.arraycopy(
+ channelBindingData, 0, cMessage, gs2Len, channelBindingData.length);
+
+ final String clientFinalMessageWithoutProof =
+ "c=" + Base64.encodeToString(cMessage, Base64.NO_WRAP) + ",r=" + nonce;
+
+ final byte[] authMessage =
+ (clientFirstMessageBare
+ + ','
+ + new String(serverFirstMessage)
+ + ','
+ + clientFinalMessageWithoutProof)
+ .getBytes();
final KeyPair keys;
try {
- keys = getKeyPair(CryptoHelper.saslPrep(account.getPassword()), salt, iterationCount);
+ keys =
+ getKeyPair(
+ CryptoHelper.saslPrep(account.getPassword()),
+ salt,
+ iterationCount);
} catch (ExecutionException e) {
throw new AuthenticationException("Invalid keys generated");
}
@@ -213,35 +217,77 @@ abstract class ScramMechanism extends SaslMechanism {
final byte[] clientProof = new byte[keys.clientKey.length];
if (clientSignature.length < keys.clientKey.length) {
- throw new AuthenticationException("client signature was shorter than clientKey");
+ throw new AuthenticationException(
+ "client signature was shorter than clientKey");
}
for (int i = 0; i < clientProof.length; i++) {
clientProof[i] = (byte) (keys.clientKey[i] ^ clientSignature[i]);
}
-
- final String clientFinalMessage = clientFinalMessageWithoutProof + ",p=" +
- Base64.encodeToString(clientProof, Base64.NO_WRAP);
+ final String clientFinalMessage =
+ clientFinalMessageWithoutProof
+ + ",p="
+ + Base64.encodeToString(clientProof, Base64.NO_WRAP);
state = State.RESPONSE_SENT;
return Base64.encodeToString(clientFinalMessage.getBytes(), Base64.NO_WRAP);
case RESPONSE_SENT:
try {
- final String clientCalculatedServerFinalMessage = "v=" +
- Base64.encodeToString(serverSignature, Base64.NO_WRAP);
- if (!clientCalculatedServerFinalMessage.equals(new String(Base64.decode(challenge, Base64.DEFAULT)))) {
+ final String clientCalculatedServerFinalMessage =
+ "v=" + Base64.encodeToString(serverSignature, Base64.NO_WRAP);
+ if (!clientCalculatedServerFinalMessage.equals(
+ new String(Base64.decode(challenge, Base64.DEFAULT)))) {
throw new Exception();
}
state = State.VALID_SERVER_RESPONSE;
return "";
} catch (Exception e) {
- throw new AuthenticationException("Server final message does not match calculated final message");
+ throw new AuthenticationException(
+ "Server final message does not match calculated final message");
}
default:
throw new InvalidStateException(state);
}
}
+ protected byte[] getChannelBindingData(final SSLSocket sslSocket)
+ throws AuthenticationException {
+ if (this.channelBinding == ChannelBinding.NONE) {
+ return new byte[0];
+ }
+ throw new AssertionError("getChannelBindingData needs to be overwritten");
+ }
+
+ private static class CacheKey {
+ final String algorithm;
+ final String password;
+ final String salt;
+ final int iterations;
+
+ private CacheKey(String algorithm, String password, String salt, int iterations) {
+ this.algorithm = algorithm;
+ this.password = password;
+ this.salt = salt;
+ this.iterations = iterations;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ CacheKey cacheKey = (CacheKey) o;
+ return iterations == cacheKey.iterations
+ && Objects.equal(algorithm, cacheKey.algorithm)
+ && Objects.equal(password, cacheKey.password)
+ && Objects.equal(salt, cacheKey.salt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(algorithm, password, salt, iterations);
+ }
+ }
+
private static class KeyPair {
final byte[] clientKey;
final byte[] serverKey;
@@ -0,0 +1,23 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import javax.net.ssl.SSLSocket;
+
+import eu.siacs.conversations.entities.Account;
+
+public abstract class ScramPlusMechanism extends ScramMechanism implements ChannelBindingMechanism {
+
+ ScramPlusMechanism(Account account, ChannelBinding channelBinding) {
+ super(account, channelBinding);
+ }
+
+ @Override
+ protected byte[] getChannelBindingData(final SSLSocket sslSocket)
+ throws AuthenticationException {
+ return ChannelBindingMechanism.getChannelBindingData(sslSocket, this.channelBinding);
+ }
+
+ @Override
+ public ChannelBinding getChannelBinding() {
+ return this.channelBinding;
+ }
+}
@@ -1,30 +1,26 @@
package eu.siacs.conversations.crypto.sasl;
-import org.bouncycastle.crypto.Digest;
-import org.bouncycastle.crypto.digests.SHA1Digest;
-import org.bouncycastle.crypto.macs.HMac;
-
-import java.security.SecureRandom;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
public class ScramSha1 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-1";
- @Override
- protected HMac getHMAC() {
- return new HMac(new SHA1Digest());
+ public ScramSha1(final Account account) {
+ super(account, ChannelBinding.NONE);
}
@Override
- protected Digest getDigest() {
- return new SHA1Digest();
+ protected HashFunction getHMac(final byte[] key) {
+ return Hashing.hmacSha1(key);
}
- public ScramSha1(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
- super(tagWriter, account, rng);
+ @Override
+ protected HashFunction getDigest() {
+ return Hashing.sha1();
}
@Override
@@ -0,0 +1,35 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class ScramSha1Plus extends ScramPlusMechanism {
+
+ public static final String MECHANISM = "SCRAM-SHA-1-PLUS";
+
+ public ScramSha1Plus(final Account account, final ChannelBinding channelBinding) {
+ super(account, channelBinding);
+ }
+
+ @Override
+ protected HashFunction getHMac(final byte[] key) {
+ return Hashing.hmacSha1(key);
+ }
+
+ @Override
+ protected HashFunction getDigest() {
+ return Hashing.sha1();
+ }
+
+ @Override
+ public int getPriority() {
+ return 35; // higher than SCRAM-SHA512 (30)
+ }
+
+ @Override
+ public String getMechanism() {
+ return MECHANISM;
+ }
+}
@@ -1,32 +1,31 @@
package eu.siacs.conversations.crypto.sasl;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.macs.HMac;
-import java.security.SecureRandom;
-
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
public class ScramSha256 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-256";
- @Override
- protected HMac getHMAC() {
- return new HMac(new SHA256Digest());
+ public ScramSha256(final Account account) {
+ super(account, ChannelBinding.NONE);
}
@Override
- protected Digest getDigest() {
- return new SHA256Digest();
+ protected HashFunction getHMac(final byte[] key) {
+ return Hashing.hmacSha256(key);
}
- public ScramSha256(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
- super(tagWriter, account, rng);
+ @Override
+ protected HashFunction getDigest() {
+ return Hashing.sha256();
}
-
@Override
public int getPriority() {
return 25;
@@ -0,0 +1,35 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class ScramSha256Plus extends ScramPlusMechanism {
+
+ public static final String MECHANISM = "SCRAM-SHA-256-PLUS";
+
+ public ScramSha256Plus(final Account account, final ChannelBinding channelBinding) {
+ super(account, channelBinding);
+ }
+
+ @Override
+ protected HashFunction getHMac(final byte[] key) {
+ return Hashing.hmacSha256(key);
+ }
+
+ @Override
+ protected HashFunction getDigest() {
+ return Hashing.sha256();
+ }
+
+ @Override
+ public int getPriority() {
+ return 40;
+ }
+
+ @Override
+ public String getMechanism() {
+ return MECHANISM;
+ }
+}
@@ -1,30 +1,30 @@
package eu.siacs.conversations.crypto.sasl;
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.digests.SHA512Digest;
import org.bouncycastle.crypto.macs.HMac;
-import java.security.SecureRandom;
-
import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.TagWriter;
public class ScramSha512 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-512";
- @Override
- protected HMac getHMAC() {
- return new HMac(new SHA512Digest());
+ public ScramSha512(final Account account) {
+ super(account, ChannelBinding.NONE);
}
@Override
- protected Digest getDigest() {
- return new SHA512Digest();
+ protected HashFunction getHMac(final byte[] key) {
+ return Hashing.hmacSha512(key);
}
- public ScramSha512(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
- super(tagWriter, account, rng);
+ @Override
+ protected HashFunction getDigest() {
+ return Hashing.sha512();
}
@Override
@@ -0,0 +1,35 @@
+package eu.siacs.conversations.crypto.sasl;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+
+import eu.siacs.conversations.entities.Account;
+
+public class ScramSha512Plus extends ScramPlusMechanism {
+
+ public static final String MECHANISM = "SCRAM-SHA-512-PLUS";
+
+ public ScramSha512Plus(final Account account, final ChannelBinding channelBinding) {
+ super(account, channelBinding);
+ }
+
+ @Override
+ protected HashFunction getHMac(final byte[] key) {
+ return Hashing.hmacSha512(key);
+ }
+
+ @Override
+ protected HashFunction getDigest() {
+ return Hashing.sha512();
+ }
+
+ @Override
+ public int getPriority() {
+ return 45;
+ }
+
+ @Override
+ public String getMechanism() {
+ return MECHANISM;
+ }
+}
@@ -6,9 +6,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
-/**
- * A tokenizer for GS2 header strings
- */
+/** A tokenizer for GS2 header strings */
public final class Tokenizer implements Iterator<String>, Iterable<String> {
private final List<String> parts;
private int index;
@@ -50,18 +48,19 @@ public final class Tokenizer implements Iterator<String>, Iterable<String> {
}
/**
- * Removes the last object returned by {@code next} from the collection.
- * This method can only be called once between each call to {@code next}.
+ * Removes the last object returned by {@code next} from the collection. This method can only be
+ * called once between each call to {@code next}.
*
* @throws UnsupportedOperationException if removing is not supported by the collection being
- * iterated.
- * @throws IllegalStateException if {@code next} has not been called, or {@code remove} has
- * already been called after the last call to {@code next}.
+ * iterated.
+ * @throws IllegalStateException if {@code next} has not been called, or {@code remove} has
+ * already been called after the last call to {@code next}.
*/
@Override
public void remove() {
if (index <= 0) {
- throw new IllegalStateException("You can't delete an element before first next() method call");
+ throw new IllegalStateException(
+ "You can't delete an element before first next() method call");
}
parts.remove(--index);
}
@@ -6,6 +6,7 @@ import android.os.SystemClock;
import android.util.Log;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
import org.json.JSONException;
import org.json.JSONObject;
@@ -24,6 +25,13 @@ import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.PgpDecryptionService;
import eu.siacs.conversations.crypto.axolotl.AxolotlService;
import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession;
+import eu.siacs.conversations.crypto.sasl.ChannelBinding;
+import eu.siacs.conversations.crypto.sasl.ChannelBindingMechanism;
+import eu.siacs.conversations.crypto.sasl.HashedToken;
+import eu.siacs.conversations.crypto.sasl.HashedTokenSha256;
+import eu.siacs.conversations.crypto.sasl.HashedTokenSha512;
+import eu.siacs.conversations.crypto.sasl.SaslMechanism;
+import eu.siacs.conversations.crypto.sasl.ScramPlusMechanism;
import eu.siacs.conversations.services.AvatarService;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.UIHelper;
@@ -49,22 +57,26 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
public static final String STATUS = "status";
public static final String STATUS_MESSAGE = "status_message";
public static final String RESOURCE = "resource";
+ public static final String PINNED_MECHANISM = "pinned_mechanism";
+ public static final String PINNED_CHANNEL_BINDING = "pinned_channel_binding";
+ public static final String FAST_MECHANISM = "fast_mechanism";
+ public static final String FAST_TOKEN = "fast_token";
- public static final String PINNED_MECHANISM_KEY = "pinned_mechanism";
- public static final String PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
-
- public static final int OPTION_USETLS = 0;
public static final int OPTION_DISABLED = 1;
public static final int OPTION_REGISTER = 2;
- public static final int OPTION_USECOMPRESSION = 3;
public static final int OPTION_MAGIC_CREATE = 4;
public static final int OPTION_REQUIRES_ACCESS_MODE_CHANGE = 5;
public static final int OPTION_LOGGED_IN_SUCCESSFULLY = 6;
public static final int OPTION_HTTP_UPLOAD_AVAILABLE = 7;
public static final int OPTION_UNVERIFIED = 8;
public static final int OPTION_FIXED_USERNAME = 9;
+ public static final int OPTION_QUICKSTART_AVAILABLE = 10;
+
private static final String KEY_PGP_SIGNATURE = "pgp_signature";
private static final String KEY_PGP_ID = "pgp_id";
+ private static final String KEY_PINNED_MECHANISM = "pinned_mechanism";
+ public static final String KEY_PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
+
protected final JSONObject keys;
private final Roster roster = new Roster(this);
private final Collection<Jid> blocklist = new CopyOnWriteArraySet<>();
@@ -90,18 +102,50 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
private long mEndGracePeriod = 0L;
private final Map<Jid, Bookmark> bookmarks = new HashMap<>();
private boolean bookmarksLoaded = false;
- private Presence.Status presenceStatus = Presence.Status.ONLINE;
- private String presenceStatusMessage = null;
+ private Presence.Status presenceStatus;
+ private String presenceStatusMessage;
+ private String pinnedMechanism;
+ private String pinnedChannelBinding;
+ private String fastMechanism;
+ private String fastToken;
public Account(final Jid jid, final String password) {
- this(java.util.UUID.randomUUID().toString(), jid,
- password, 0, null, "", null, null, null, 5222, Presence.Status.ONLINE, null);
- }
-
- private Account(final String uuid, final Jid jid,
- final String password, final int options, final String rosterVersion, final String keys,
- final String avatar, String displayName, String hostname, int port,
- final Presence.Status status, String statusMessage) {
+ this(
+ java.util.UUID.randomUUID().toString(),
+ jid,
+ password,
+ 0,
+ null,
+ "",
+ null,
+ null,
+ null,
+ 5222,
+ Presence.Status.ONLINE,
+ null,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ private Account(
+ final String uuid,
+ final Jid jid,
+ final String password,
+ final int options,
+ final String rosterVersion,
+ final String keys,
+ final String avatar,
+ String displayName,
+ String hostname,
+ int port,
+ final Presence.Status status,
+ String statusMessage,
+ final String pinnedMechanism,
+ final String pinnedChannelBinding,
+ final String fastMechanism,
+ final String fastToken) {
this.uuid = uuid;
this.jid = jid;
this.password = password;
@@ -120,36 +164,51 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
this.port = port;
this.presenceStatus = status;
this.presenceStatusMessage = statusMessage;
+ this.pinnedMechanism = pinnedMechanism;
+ this.pinnedChannelBinding = pinnedChannelBinding;
+ this.fastMechanism = fastMechanism;
+ this.fastToken = fastToken;
}
public static Account fromCursor(final Cursor cursor) {
final Jid jid;
try {
- String resource = cursor.getString(cursor.getColumnIndex(RESOURCE));
- jid = Jid.of(
- cursor.getString(cursor.getColumnIndex(USERNAME)),
- cursor.getString(cursor.getColumnIndex(SERVER)),
- resource == null || resource.trim().isEmpty() ? null : resource);
- } catch (final IllegalArgumentException ignored) {
- Log.d(Config.LOGTAG, cursor.getString(cursor.getColumnIndex(USERNAME)) + "@" + cursor.getString(cursor.getColumnIndex(SERVER)));
- throw new AssertionError(ignored);
- }
- return new Account(cursor.getString(cursor.getColumnIndex(UUID)),
+ final String resource = cursor.getString(cursor.getColumnIndexOrThrow(RESOURCE));
+ jid =
+ Jid.of(
+ cursor.getString(cursor.getColumnIndexOrThrow(USERNAME)),
+ cursor.getString(cursor.getColumnIndexOrThrow(SERVER)),
+ resource == null || resource.trim().isEmpty() ? null : resource);
+ } catch (final IllegalArgumentException e) {
+ Log.d(
+ Config.LOGTAG,
+ cursor.getString(cursor.getColumnIndexOrThrow(USERNAME))
+ + "@"
+ + cursor.getString(cursor.getColumnIndexOrThrow(SERVER)));
+ throw new AssertionError(e);
+ }
+ return new Account(
+ cursor.getString(cursor.getColumnIndexOrThrow(UUID)),
jid,
- cursor.getString(cursor.getColumnIndex(PASSWORD)),
- cursor.getInt(cursor.getColumnIndex(OPTIONS)),
- cursor.getString(cursor.getColumnIndex(ROSTERVERSION)),
- cursor.getString(cursor.getColumnIndex(KEYS)),
- cursor.getString(cursor.getColumnIndex(AVATAR)),
- cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)),
- cursor.getString(cursor.getColumnIndex(HOSTNAME)),
- cursor.getInt(cursor.getColumnIndex(PORT)),
- Presence.Status.fromShowString(cursor.getString(cursor.getColumnIndex(STATUS))),
- cursor.getString(cursor.getColumnIndex(STATUS_MESSAGE)));
- }
-
- public boolean httpUploadAvailable(long filesize) {
- return xmppConnection != null && xmppConnection.getFeatures().httpUpload(filesize);
+ cursor.getString(cursor.getColumnIndexOrThrow(PASSWORD)),
+ cursor.getInt(cursor.getColumnIndexOrThrow(OPTIONS)),
+ cursor.getString(cursor.getColumnIndexOrThrow(ROSTERVERSION)),
+ cursor.getString(cursor.getColumnIndexOrThrow(KEYS)),
+ cursor.getString(cursor.getColumnIndexOrThrow(AVATAR)),
+ cursor.getString(cursor.getColumnIndexOrThrow(DISPLAY_NAME)),
+ cursor.getString(cursor.getColumnIndexOrThrow(HOSTNAME)),
+ cursor.getInt(cursor.getColumnIndexOrThrow(PORT)),
+ Presence.Status.fromShowString(
+ cursor.getString(cursor.getColumnIndexOrThrow(STATUS))),
+ cursor.getString(cursor.getColumnIndexOrThrow(STATUS_MESSAGE)),
+ cursor.getString(cursor.getColumnIndexOrThrow(PINNED_MECHANISM)),
+ cursor.getString(cursor.getColumnIndexOrThrow(PINNED_CHANNEL_BINDING)),
+ cursor.getString(cursor.getColumnIndexOrThrow(FAST_MECHANISM)),
+ cursor.getString(cursor.getColumnIndexOrThrow(FAST_TOKEN)));
+ }
+
+ public boolean httpUploadAvailable(long size) {
+ return xmppConnection != null && xmppConnection.getFeatures().httpUpload(size);
}
public boolean httpUploadAvailable() {
@@ -289,6 +348,78 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
}
}
+ public void setPinnedMechanism(final SaslMechanism mechanism) {
+ this.pinnedMechanism = mechanism.getMechanism();
+ if (mechanism instanceof ChannelBindingMechanism) {
+ this.pinnedChannelBinding =
+ ((ChannelBindingMechanism) mechanism).getChannelBinding().toString();
+ } else {
+ this.pinnedChannelBinding = null;
+ }
+ }
+
+ public void setFastToken(final HashedToken.Mechanism mechanism, final String token) {
+ this.fastMechanism = mechanism.name();
+ this.fastToken = token;
+ }
+
+ public void resetFastToken() {
+ this.fastMechanism = null;
+ this.fastToken = null;
+ }
+
+ public void resetPinnedMechanism() {
+ this.pinnedMechanism = null;
+ this.pinnedChannelBinding = null;
+ setKey(Account.KEY_PINNED_MECHANISM, String.valueOf(-1));
+ }
+
+ public int getPinnedMechanismPriority() {
+ final int fallback = getKeyAsInt(KEY_PINNED_MECHANISM, -1);
+ if (Strings.isNullOrEmpty(this.pinnedMechanism)) {
+ return fallback;
+ }
+ final SaslMechanism saslMechanism = getPinnedMechanism();
+ if (saslMechanism == null) {
+ return fallback;
+ } else {
+ return saslMechanism.getPriority();
+ }
+ }
+
+ private SaslMechanism getPinnedMechanism() {
+ final String mechanism = Strings.nullToEmpty(this.pinnedMechanism);
+ final ChannelBinding channelBinding = ChannelBinding.get(this.pinnedChannelBinding);
+ return new SaslMechanism.Factory(this).of(mechanism, channelBinding);
+ }
+
+ public HashedToken getFastMechanism() {
+ final HashedToken.Mechanism fastMechanism = HashedToken.Mechanism.ofOrNull(this.fastMechanism);
+ final String token = this.fastToken;
+ if (fastMechanism == null || Strings.isNullOrEmpty(token)) {
+ return null;
+ }
+ if (fastMechanism.hashFunction.equals("SHA-256")) {
+ return new HashedTokenSha256(this, fastMechanism.channelBinding);
+ } else if (fastMechanism.hashFunction.equals("SHA-512")) {
+ return new HashedTokenSha512(this, fastMechanism.channelBinding);
+ } else {
+ return null;
+ }
+ }
+
+ public SaslMechanism getQuickStartMechanism() {
+ final HashedToken hashedTokenMechanism = getFastMechanism();
+ if (hashedTokenMechanism != null) {
+ return hashedTokenMechanism;
+ }
+ return getPinnedMechanism();
+ }
+
+ public String getFastToken() {
+ return this.fastToken;
+ }
+
public State getTrueStatus() {
return this.status;
}
@@ -361,8 +492,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
}
}
- public boolean setPrivateKeyAlias(String alias) {
- return setKey("private_key_alias", alias);
+ public void setPrivateKeyAlias(final String alias) {
+ setKey("private_key_alias", alias);
}
public String getPrivateKeyAlias() {
@@ -388,6 +519,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
values.put(STATUS, presenceStatus.toShowString());
values.put(STATUS_MESSAGE, presenceStatusMessage);
values.put(RESOURCE, jid.getResource());
+ values.put(PINNED_MECHANISM, pinnedMechanism);
+ values.put(PINNED_CHANNEL_BINDING, pinnedChannelBinding);
+ values.put(FAST_MECHANISM, this.fastMechanism);
+ values.put(FAST_TOKEN, this.fastToken);
return values;
}
@@ -433,7 +568,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
public int activeDevicesWithRtpCapability() {
int i = 0;
- for(Presence presence : getSelfContact().getPresences().getPresences()) {
+ for (Presence presence : getSelfContact().getPresences().getPresences()) {
if (RtpCapability.check(presence) != RtpCapability.Capability.NONE) {
i++;
}
@@ -490,13 +625,13 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
public Collection<Bookmark> getBookmarks() {
synchronized (this.bookmarks) {
- return new HashSet<>(this.bookmarks.values());
+ return ImmutableList.copyOf(this.bookmarks.values());
}
}
public boolean areBookmarksLoaded() { return bookmarksLoaded; }
- public void setBookmarks(Map<Jid, Bookmark> bookmarks) {
+ public void setBookmarks(final Map<Jid, Bookmark> bookmarks) {
synchronized (this.bookmarks) {
this.bookmarks.clear();
this.bookmarks.putAll(bookmarks);
@@ -504,7 +639,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
}
}
- public void putBookmark(Bookmark bookmark) {
+ public void putBookmark(final Bookmark bookmark) {
synchronized (this.bookmarks) {
this.bookmarks.put(bookmark.getJid(), bookmark);
}
@@ -573,7 +708,9 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
public String getShareableLink() {
List<XmppUri.Fingerprint> fingerprints = this.getFingerprints();
- String uri = "https://conversations.im/i/" + XmppUri.lameUrlEncode(this.getJid().asBareJid().toEscapedString());
+ String uri =
+ "https://conversations.im/i/"
+ + XmppUri.lameUrlEncode(this.getJid().asBareJid().toEscapedString());
if (fingerprints.size() > 0) {
return XmppUri.getFingerprintUri(uri, fingerprints, '&');
} else {
@@ -586,10 +723,18 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
if (axolotlService == null) {
return fingerprints;
}
- fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OMEMO, axolotlService.getOwnFingerprint().substring(2), axolotlService.getOwnDeviceId()));
+ fingerprints.add(
+ new XmppUri.Fingerprint(
+ XmppUri.FingerprintType.OMEMO,
+ axolotlService.getOwnFingerprint().substring(2),
+ axolotlService.getOwnDeviceId()));
for (XmppAxolotlSession session : axolotlService.findOwnSessions()) {
if (session.getTrust().isVerified() && session.getTrust().isActive()) {
- fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OMEMO, session.getFingerprint().substring(2).replaceAll("\\s", ""), session.getRemoteAddress().getDeviceId()));
+ fingerprints.add(
+ new XmppUri.Fingerprint(
+ XmppUri.FingerprintType.OMEMO,
+ session.getFingerprint().substring(2).replaceAll("\\s", ""),
+ session.getRemoteAddress().getDeviceId()));
}
}
return fingerprints;
@@ -597,7 +742,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
public boolean isBlocked(final ListItem contact) {
final Jid jid = contact.getJid();
- return jid != null && (blocklist.contains(jid.asBareJid()) || blocklist.contains(jid.getDomain()));
+ return jid != null
+ && (blocklist.contains(jid.asBareJid()) || blocklist.contains(jid.getDomain()));
}
public boolean isBlocked(final Jid jid) {
@@ -641,11 +787,12 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
REGISTRATION_CONFLICT(true, false),
REGISTRATION_NOT_SUPPORTED(true, false),
REGISTRATION_PLEASE_WAIT(true, false),
- REGISTRATION_INVALID_TOKEN(true,false),
+ REGISTRATION_INVALID_TOKEN(true, false),
REGISTRATION_PASSWORD_TOO_WEAK(true, false),
TLS_ERROR,
TLS_ERROR_DOMAIN,
INCOMPATIBLE_SERVER,
+ INCOMPATIBLE_CLIENT,
TOR_NOT_AVAILABLE,
DOWNGRADE_ATTACK,
SESSION_FAILURE,
@@ -715,6 +862,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
return R.string.account_status_tls_error_domain;
case INCOMPATIBLE_SERVER:
return R.string.account_status_incompatible_server;
+ case INCOMPATIBLE_CLIENT:
+ return R.string.account_status_incompatible_client;
case TOR_NOT_AVAILABLE:
return R.string.account_status_tor_unavailable;
case BIND_FAILURE:
@@ -156,7 +156,8 @@ public class MucOptions {
}
public boolean canInvite() {
- return !membersOnly() || self.getRole().ranks(Role.MODERATOR) || allowInvites();
+ final boolean hasPermission = !membersOnly() || self.getRole().ranks(Role.MODERATOR) || allowInvites();
+ return hasPermission && online();
}
public boolean allowInvites() {
@@ -725,6 +726,7 @@ public class MucOptions {
SHUTDOWN,
DESTROYED,
INVALID_NICK,
+ TECHNICAL_PROBLEMS,
UNKNOWN,
NON_ANONYMOUS
}
@@ -145,8 +145,8 @@ public class IqGenerator extends AbstractGenerator {
return publish(Namespace.NICK, item);
}
- public IqPacket deleteNode(String node) {
- IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
+ public IqPacket deleteNode(final String node) {
+ final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
final Element pubsub = packet.addChild("pubsub", Namespace.PUBSUB_OWNER);
pubsub.addChild("delete").setAttribute("node", node);
return packet;
@@ -165,9 +165,9 @@ public class IqGenerator extends AbstractGenerator {
public IqPacket publishAvatar(Avatar avatar, Bundle options) {
final Element item = new Element("item");
item.setAttribute("id", avatar.sha1sum);
- final Element data = item.addChild("data", "urn:xmpp:avatar:data");
+ final Element data = item.addChild("data", Namespace.AVATAR_DATA);
data.setContent(avatar.image);
- return publish("urn:xmpp:avatar:data", item, options);
+ return publish(Namespace.AVATAR_DATA, item, options);
}
public IqPacket publishElement(final String namespace, final Element element, String id, final Bundle options) {
@@ -181,20 +181,20 @@ public class IqGenerator extends AbstractGenerator {
final Element item = new Element("item");
item.setAttribute("id", avatar.sha1sum);
final Element metadata = item
- .addChild("metadata", "urn:xmpp:avatar:metadata");
+ .addChild("metadata", Namespace.AVATAR_METADATA);
final Element info = metadata.addChild("info");
info.setAttribute("bytes", avatar.size);
info.setAttribute("id", avatar.sha1sum);
info.setAttribute("height", avatar.height);
info.setAttribute("width", avatar.height);
info.setAttribute("type", avatar.type);
- return publish("urn:xmpp:avatar:metadata", item, options);
+ return publish(Namespace.AVATAR_METADATA, item, options);
}
public IqPacket retrievePepAvatar(final Avatar avatar) {
final Element item = new Element("item");
item.setAttribute("id", avatar.sha1sum);
- final IqPacket packet = retrieve("urn:xmpp:avatar:data", item);
+ final IqPacket packet = retrieve(Namespace.AVATAR_DATA, item);
packet.setTo(avatar.owner);
return packet;
}
@@ -206,6 +206,13 @@ public class IqGenerator extends AbstractGenerator {
return packet;
}
+ public IqPacket retrieveVcardAvatar(final Jid to) {
+ final IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
+ packet.setTo(to);
+ packet.addChild("vCard", "vcard-temp");
+ return packet;
+ }
+
public IqPacket retrieveAvatarMetaData(final Jid to) {
final IqPacket packet = retrieve("urn:xmpp:avatar:metadata", null);
if (to != null) {
@@ -1,5 +1,7 @@
package eu.siacs.conversations.http;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
import android.os.Build;
import android.util.Log;
@@ -147,7 +149,7 @@ public class HttpConnectionManager extends AbstractConnectionManager {
trustManager = mXmppConnectionService.getMemorizingTrustManager().getNonInteractive();
}
try {
- final SSLSocketFactory sf = new TLSSocketFactory(new X509TrustManager[]{trustManager}, mXmppConnectionService.getRNG());
+ final SSLSocketFactory sf = new TLSSocketFactory(new X509TrustManager[]{trustManager}, SECURE_RANDOM);
builder.sslSocketFactory(sf, trustManager);
builder.hostnameVerifier(new StrictHostnameVerifier());
} catch (final KeyManagementException | NoSuchAlgorithmException ignored) {
@@ -1,5 +1,7 @@
package eu.siacs.conversations.http;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
import android.util.Log;
import androidx.annotation.NonNull;
@@ -124,7 +126,7 @@ public class HttpUploadConnection implements Transferable, AbstractConnectionMan
|| message.getEncryption() == Message.ENCRYPTION_AXOLOTL
|| message.getEncryption() == Message.ENCRYPTION_OTR) {
this.key = new byte[44];
- mXmppConnectionService.getRNG().nextBytes(this.key);
+ SECURE_RANDOM.nextBytes(this.key);
this.file.setKeyAndIv(this.key);
}
this.file.setExpectedSize(originalFileSize + (file.getKey() != null ? 16 : 0));
@@ -236,7 +236,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
Element item = items.findChild("item");
Set<Integer> deviceIds = mXmppConnectionService.getIqParser().deviceIds(item);
Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received PEP device list " + deviceIds + " update from " + from + ", processing... ");
- AxolotlService axolotlService = account.getAxolotlService();
+ final AxolotlService axolotlService = account.getAxolotlService();
axolotlService.registerDevices(from, deviceIds);
} else if (Namespace.BOOKMARKS.equals(node) && account.getJid().asBareJid().equals(from)) {
if (account.getXmppConnection().getFeatures().bookmarksConversion()) {
@@ -282,6 +282,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
} 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");
+ } else if (Namespace.AVATAR_METADATA.equals(node) && account.getJid().asBareJid().equals(from)) {
+ Log.d(Config.LOGTAG,account.getJid().asBareJid()+": deleted avatar metadata node");
}
}
@@ -314,7 +316,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
private boolean handleErrorMessage(final Account account, final MessagePacket packet) {
if (packet.getType() == MessagePacket.TYPE_ERROR) {
if (packet.fromServer(account)) {
- final Pair<MessagePacket, Long> forwarded = packet.getForwardedMessagePacket("received", "urn:xmpp:carbons:2");
+ final Pair<MessagePacket, Long> forwarded = packet.getForwardedMessagePacket("received", Namespace.CARBONS);
if (forwarded != null) {
return handleErrorMessage(account, forwarded.first);
}
@@ -390,8 +392,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
return;
} else if (original.fromServer(account)) {
Pair<MessagePacket, Long> f;
- f = original.getForwardedMessagePacket("received", "urn:xmpp:carbons:2");
- f = f == null ? original.getForwardedMessagePacket("sent", "urn:xmpp:carbons:2") : f;
+ f = original.getForwardedMessagePacket("received", Namespace.CARBONS);
+ f = f == null ? original.getForwardedMessagePacket("sent", Namespace.CARBONS) : f;
packet = f != null ? f.first : original;
if (handleErrorMessage(account, packet)) {
return;
@@ -56,7 +56,8 @@ public class PresenceParser extends AbstractParser implements
}
private void processConferencePresence(PresencePacket packet, Conversation conversation) {
- MucOptions mucOptions = conversation.getMucOptions();
+ final Account account = conversation.getAccount();
+ final MucOptions mucOptions = conversation.getMucOptions();
final Jid jid = conversation.getAccount().getJid();
final Jid from = packet.getFrom();
if (!from.isBareJid()) {
@@ -93,7 +94,7 @@ public class PresenceParser extends AbstractParser implements
axolotlService.fetchDeviceIds(user.getRealJid());
}
if (codes.contains(MucOptions.STATUS_CODE_ROOM_CREATED) && mucOptions.autoPushConfiguration()) {
- Log.d(Config.LOGTAG,mucOptions.getAccount().getJid().asBareJid()
+ Log.d(Config.LOGTAG,account.getJid().asBareJid()
+": room '"
+mucOptions.getConversation().getJid().asBareJid()
+"' created. pushing default configuration");
@@ -138,13 +139,24 @@ public class PresenceParser extends AbstractParser implements
final Jid alternate = destroy == null ? null : InvalidJid.getNullForInvalid(destroy.getAttributeAsJid("jid"));
mucOptions.setError(MucOptions.Error.DESTROYED);
if (alternate != null) {
- Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": muc destroyed. alternate location " + alternate);
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": muc destroyed. alternate location " + alternate);
}
} else if (codes.contains(MucOptions.STATUS_CODE_SHUTDOWN) && fullJidMatches) {
mucOptions.setError(MucOptions.Error.SHUTDOWN);
} else if (codes.contains(MucOptions.STATUS_CODE_SELF_PRESENCE)) {
if (codes.contains(MucOptions.STATUS_CODE_TECHNICAL_REASONS)) {
- mucOptions.setError(MucOptions.Error.UNKNOWN);
+ final boolean wasOnline = mucOptions.online();
+ mucOptions.setError(MucOptions.Error.TECHNICAL_PROBLEMS);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": received status code 333 in room "
+ + mucOptions.getConversation().getJid().asBareJid()
+ + " online="
+ + wasOnline);
+ if (wasOnline) {
+ mXmppConnectionService.mucSelfPingAndRejoin(conversation);
+ }
} else if (codes.contains(MucOptions.STATUS_CODE_KICKED)) {
mucOptions.setError(MucOptions.Error.KICKED);
} else if (codes.contains(MucOptions.STATUS_CODE_BANNED)) {
@@ -67,7 +67,7 @@ import eu.siacs.conversations.xmpp.mam.MamReference;
public class DatabaseBackend extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "history";
- private static final int DATABASE_VERSION = 49;
+ private static final int DATABASE_VERSION = 51;
private static boolean requiresMessageIndexRebuild = false;
private static DatabaseBackend instance = null;
@@ -294,6 +294,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
+ Account.KEYS + " TEXT, "
+ Account.HOSTNAME + " TEXT, "
+ Account.RESOURCE + " TEXT,"
+ + Account.PINNED_MECHANISM + " TEXT,"
+ + Account.PINNED_CHANNEL_BINDING + " TEXT,"
+ + Account.FAST_MECHANISM + " TEXT,"
+ + Account.FAST_TOKEN + " TEXT,"
+ Account.PORT + " NUMBER DEFAULT 5222)");
db.execSQL("create table " + Conversation.TABLENAME + " ("
+ Conversation.UUID + " TEXT PRIMARY KEY, " + Conversation.NAME
@@ -653,6 +657,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
db.endTransaction();
requiresMessageIndexRebuild = true;
}
+ if (oldVersion < 50 && newVersion >= 50) {
+ db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.PINNED_MECHANISM + " TEXT");
+ db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.PINNED_CHANNEL_BINDING + " TEXT");
+ }
+ if (oldVersion < 51 && newVersion >= 51) {
+ db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.FAST_MECHANISM + " TEXT");
+ db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.FAST_TOKEN + " TEXT");
+ }
}
private void canonicalizeJids(SQLiteDatabase db) {
@@ -1034,20 +1046,19 @@ public class DatabaseBackend extends SQLiteOpenHelper {
contactJid.asBareJid().toString() + "/%",
contactJid.asBareJid().toString()
};
- Cursor cursor = db.query(Conversation.TABLENAME, null,
+ try(final Cursor cursor = db.query(Conversation.TABLENAME, null,
Conversation.ACCOUNT + "=? AND (" + Conversation.CONTACTJID
- + " like ? OR " + Conversation.CONTACTJID + "=?)", selectionArgs, null, null, null);
- if (cursor.getCount() == 0) {
- cursor.close();
- return null;
- }
- cursor.moveToFirst();
- Conversation conversation = Conversation.fromCursor(cursor);
- cursor.close();
- if (conversation.getJid() instanceof InvalidJid) {
- return null;
+ + " like ? OR " + Conversation.CONTACTJID + "=?)", selectionArgs, null, null, null)) {
+ if (cursor.getCount() == 0) {
+ return null;
+ }
+ cursor.moveToFirst();
+ final Conversation conversation = Conversation.fromCursor(cursor);
+ if (conversation.getJid() instanceof InvalidJid) {
+ return null;
+ }
+ return conversation;
}
- return conversation;
}
public void updateConversation(final Conversation conversation) {
@@ -1063,33 +1074,28 @@ public class DatabaseBackend extends SQLiteOpenHelper {
}
public List<Jid> getAccountJids(final boolean enabledOnly) {
- SQLiteDatabase db = this.getReadableDatabase();
+ final SQLiteDatabase db = this.getReadableDatabase();
final List<Jid> jids = new ArrayList<>();
final String[] columns = new String[]{Account.USERNAME, Account.SERVER};
- String where = enabledOnly ? "not options & (1 <<1)" : null;
- Cursor cursor = db.query(Account.TABLENAME, columns, where, null, null, null, null);
- try {
- while (cursor.moveToNext()) {
+ final String where = enabledOnly ? "not options & (1 <<1)" : null;
+ try (final Cursor cursor = db.query(Account.TABLENAME, columns, where, null, null, null, null)) {
+ while (cursor != null && cursor.moveToNext()) {
jids.add(Jid.of(cursor.getString(0), cursor.getString(1), null));
}
+ } catch (final Exception e) {
return jids;
- } catch (Exception e) {
- return jids;
- } finally {
- if (cursor != null) {
- cursor.close();
- }
}
+ return jids;
}
private List<Account> getAccounts(SQLiteDatabase db) {
- List<Account> list = new ArrayList<>();
- Cursor cursor = db.query(Account.TABLENAME, null, null, null, null,
- null, null);
- while (cursor.moveToNext()) {
- list.add(Account.fromCursor(cursor));
+ final List<Account> list = new ArrayList<>();
+ try (final Cursor cursor =
+ db.query(Account.TABLENAME, null, null, null, null, null, null)) {
+ while (cursor != null && cursor.moveToNext()) {
+ list.add(Account.fromCursor(cursor));
+ }
}
- cursor.close();
return list;
}
@@ -1127,14 +1133,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
}
public void readRoster(Roster roster) {
- SQLiteDatabase db = this.getReadableDatabase();
- Cursor cursor;
- String[] args = {roster.getAccount().getUuid()};
- cursor = db.query(Contact.TABLENAME, null, Contact.ACCOUNT + "=?", args, null, null, null);
- while (cursor.moveToNext()) {
- roster.initContact(Contact.fromCursor(cursor));
+ final SQLiteDatabase db = this.getReadableDatabase();
+ final String[] args = {roster.getAccount().getUuid()};
+ try (final Cursor cursor =
+ db.query(Contact.TABLENAME, null, Contact.ACCOUNT + "=?", args, null, null, null)) {
+ while (cursor.moveToNext()) {
+ roster.initContact(Contact.fromCursor(cursor));
+ }
}
- cursor.close();
}
public void writeRoster(final Roster roster) {
@@ -694,7 +694,7 @@ public class FileBackend {
} catch (final FileWriterException e) {
cleanup(file);
throw new FileCopyException(R.string.error_unable_to_create_temporary_file);
- } catch (final SecurityException e) {
+ } catch (final SecurityException | IllegalStateException e) {
cleanup(file);
throw new FileCopyException(R.string.error_security_exception);
} catch (final IOException e) {
@@ -1687,19 +1687,19 @@ public class FileBackend {
return 0;
}
return Integer.parseInt(value);
- } catch (final IllegalArgumentException e) {
+ } catch (final Exception e) {
return 0;
}
}
private Dimensions getImageDimensions(File file) {
- BitmapFactory.Options options = new BitmapFactory.Options();
+ final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), options);
- int rotation = getRotation(file);
- boolean rotated = rotation == 90 || rotation == 270;
- int imageHeight = rotated ? options.outWidth : options.outHeight;
- int imageWidth = rotated ? options.outHeight : options.outWidth;
+ final int rotation = getRotation(file);
+ final boolean rotated = rotation == 90 || rotation == 270;
+ final int imageHeight = rotated ? options.outWidth : options.outHeight;
+ final int imageWidth = rotated ? options.outHeight : options.outWidth;
return new Dimensions(imageHeight, imageWidth);
}
@@ -1713,7 +1713,6 @@ public class FileBackend {
return getVideoDimensions(metadataRetriever);
}
- @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
private Dimensions getPdfDocumentDimensions(final File file) {
final ParcelFileDescriptor fileDescriptor;
try {
@@ -1721,7 +1720,7 @@ public class FileBackend {
if (fileDescriptor == null) {
return new Dimensions(0, 0);
}
- } catch (FileNotFoundException e) {
+ } catch (final FileNotFoundException e) {
return new Dimensions(0, 0);
}
try {
@@ -1732,7 +1731,7 @@ public class FileBackend {
page.close();
pdfRenderer.close();
return scalePdfDimensions(new Dimensions(height, width));
- } catch (IOException | SecurityException e) {
+ } catch (final IOException | SecurityException e) {
Log.d(Config.LOGTAG, "unable to get dimensions for pdf document", e);
return new Dimensions(0, 0);
}
@@ -33,6 +33,7 @@ import java.util.concurrent.CountDownLatch;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
+import eu.siacs.conversations.xmpp.jingle.Media;
/**
* AppRTCAudioManager manages all audio related parts of the AppRTC demo.
@@ -44,7 +45,7 @@ public class AppRTCAudioManager {
private final Context apprtcContext;
// Contains speakerphone setting: auto, true or false
@Nullable
- private final SpeakerPhonePreference speakerPhonePreference;
+ private SpeakerPhonePreference speakerPhonePreference;
// Handles all tasks related to Bluetooth headset devices.
private final AppRTCBluetoothManager bluetoothManager;
@Nullable
@@ -110,6 +111,16 @@ public class AppRTCAudioManager {
AppRTCUtils.logDeviceInfo(Config.LOGTAG);
}
+ public void switchSpeakerPhonePreference(final SpeakerPhonePreference speakerPhonePreference) {
+ this.speakerPhonePreference = speakerPhonePreference;
+ if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
+ defaultAudioDevice = AudioDevice.EARPIECE;
+ } else {
+ defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
+ }
+ updateAudioDeviceState();
+ }
+
/**
* Construction.
*/
@@ -587,7 +598,15 @@ public class AppRTCAudioManager {
}
public enum SpeakerPhonePreference {
- AUTO, EARPIECE, SPEAKER
+ AUTO, EARPIECE, SPEAKER;
+
+ public static SpeakerPhonePreference of(final Set<Media> media) {
+ if (media.contains(Media.VIDEO)) {
+ return SPEAKER;
+ } else {
+ return EARPIECE;
+ }
+ }
}
/**
@@ -4,6 +4,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
+import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
@@ -39,7 +40,6 @@ public class ChannelDiscoveryService {
private final XmppConnectionService service;
-
private MuclumbusService muclumbusService;
private final Cache<String, List<Room>> cache;
@@ -50,16 +50,21 @@ public class ChannelDiscoveryService {
}
void initializeMuclumbusService() {
+ if (Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
+ this.muclumbusService = null;
+ return;
+ }
final OkHttpClient.Builder builder = HttpConnectionManager.OK_HTTP_CLIENT.newBuilder();
if (service.useTorToConnect()) {
builder.proxy(HttpConnectionManager.getProxy());
}
- Retrofit retrofit = new Retrofit.Builder()
- .client(builder.build())
- .baseUrl(Config.CHANNEL_DISCOVERY)
- .addConverterFactory(GsonConverterFactory.create())
- .callbackExecutor(Executors.newSingleThreadExecutor())
- .build();
+ final Retrofit retrofit =
+ new Retrofit.Builder()
+ .client(builder.build())
+ .baseUrl(Config.CHANNEL_DISCOVERY)
+ .addConverterFactory(GsonConverterFactory.create())
+ .callbackExecutor(Executors.newSingleThreadExecutor())
+ .build();
this.muclumbusService = retrofit.create(MuclumbusService.class);
}
@@ -67,7 +72,10 @@ public class ChannelDiscoveryService {
cache.invalidateAll();
}
- void discover(@NonNull final String query, Method method, OnChannelSearchResultsFound onChannelSearchResultsFound) {
+ void discover(
+ @NonNull final String query,
+ Method method,
+ OnChannelSearchResultsFound onChannelSearchResultsFound) {
final List<Room> result = cache.getIfPresent(key(method, query));
if (result != null) {
onChannelSearchResultsFound.onChannelSearchResultsFound(result);
@@ -84,59 +92,82 @@ public class ChannelDiscoveryService {
}
}
- private void discoverChannelsJabberNetwork(OnChannelSearchResultsFound listener) {
- Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
- try {
- call.enqueue(new Callback<MuclumbusService.Rooms>() {
- @Override
- public void onResponse(@NonNull Call<MuclumbusService.Rooms> call, @NonNull Response<MuclumbusService.Rooms> response) {
- final MuclumbusService.Rooms body = response.body();
- if (body == null) {
- listener.onChannelSearchResultsFound(Collections.emptyList());
- logError(response);
- return;
+ private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
+ if (muclumbusService == null) {
+ listener.onChannelSearchResultsFound(Collections.emptyList());
+ return;
+ }
+ final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
+ call.enqueue(
+ new Callback<MuclumbusService.Rooms>() {
+ @Override
+ public void onResponse(
+ @NonNull Call<MuclumbusService.Rooms> call,
+ @NonNull Response<MuclumbusService.Rooms> response) {
+ final MuclumbusService.Rooms body = response.body();
+ if (body == null) {
+ listener.onChannelSearchResultsFound(Collections.emptyList());
+ logError(response);
+ return;
+ }
+ cache.put(key(Method.JABBER_NETWORK, ""), body.items);
+ listener.onChannelSearchResultsFound(body.items);
}
- cache.put(key(Method.JABBER_NETWORK, ""), body.items);
- listener.onChannelSearchResultsFound(body.items);
- }
- @Override
- public void onFailure(@NonNull Call<MuclumbusService.Rooms> call, @NonNull Throwable throwable) {
- Log.d(Config.LOGTAG, "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY, throwable);
- listener.onChannelSearchResultsFound(Collections.emptyList());
- }
- });
- } catch (Exception e) {
- e.printStackTrace();
- }
+ @Override
+ public void onFailure(
+ @NonNull Call<MuclumbusService.Rooms> call,
+ @NonNull Throwable throwable) {
+ Log.d(
+ Config.LOGTAG,
+ "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
+ throwable);
+ listener.onChannelSearchResultsFound(Collections.emptyList());
+ }
+ });
}
- private void discoverChannelsJabberNetwork(final String query, OnChannelSearchResultsFound listener) {
- MuclumbusService.SearchRequest searchRequest = new MuclumbusService.SearchRequest(query);
- Call<MuclumbusService.SearchResult> searchResultCall = muclumbusService.search(searchRequest);
-
- searchResultCall.enqueue(new Callback<MuclumbusService.SearchResult>() {
- @Override
- public void onResponse(@NonNull Call<MuclumbusService.SearchResult> call, @NonNull Response<MuclumbusService.SearchResult> response) {
- final MuclumbusService.SearchResult body = response.body();
- if (body == null) {
- listener.onChannelSearchResultsFound(Collections.emptyList());
- logError(response);
- return;
- }
- cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
- listener.onChannelSearchResultsFound(body.result.items);
- }
+ private void discoverChannelsJabberNetwork(
+ final String query, final OnChannelSearchResultsFound listener) {
+ if (muclumbusService == null) {
+ listener.onChannelSearchResultsFound(Collections.emptyList());
+ return;
+ }
+ final MuclumbusService.SearchRequest searchRequest =
+ new MuclumbusService.SearchRequest(query);
+ final Call<MuclumbusService.SearchResult> searchResultCall =
+ muclumbusService.search(searchRequest);
+ searchResultCall.enqueue(
+ new Callback<MuclumbusService.SearchResult>() {
+ @Override
+ public void onResponse(
+ @NonNull Call<MuclumbusService.SearchResult> call,
+ @NonNull Response<MuclumbusService.SearchResult> response) {
+ final MuclumbusService.SearchResult body = response.body();
+ if (body == null) {
+ listener.onChannelSearchResultsFound(Collections.emptyList());
+ logError(response);
+ return;
+ }
+ cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
+ listener.onChannelSearchResultsFound(body.result.items);
+ }
- @Override
- public void onFailure(@NonNull Call<MuclumbusService.SearchResult> call, @NonNull Throwable throwable) {
- Log.d(Config.LOGTAG, "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY, throwable);
- listener.onChannelSearchResultsFound(Collections.emptyList());
- }
- });
+ @Override
+ public void onFailure(
+ @NonNull Call<MuclumbusService.SearchResult> call,
+ @NonNull Throwable throwable) {
+ Log.d(
+ Config.LOGTAG,
+ "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
+ throwable);
+ listener.onChannelSearchResultsFound(Collections.emptyList());
+ }
+ });
}
- private void discoverChannelsLocalServers(final String query, final OnChannelSearchResultsFound listener) {
+ private void discoverChannelsLocalServers(
+ final String query, final OnChannelSearchResultsFound listener) {
final Map<Jid, Account> localMucService = getLocalMucServices();
Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
if (localMucService.size() == 0) {
@@ -156,38 +187,49 @@ public class ChannelDiscoveryService {
for (Map.Entry<Jid, Account> entry : localMucService.entrySet()) {
IqPacket itemsRequest = service.getIqGenerator().queryDiscoItems(entry.getKey());
queriesInFlight.incrementAndGet();
- service.sendIqPacket(entry.getValue(), itemsRequest, (account, itemsResponse) -> {
- if (itemsResponse.getType() == IqPacket.TYPE.RESULT) {
- final List<Jid> items = IqParser.items(itemsResponse);
- for (Jid item : items) {
- IqPacket infoRequest = service.getIqGenerator().queryDiscoInfo(item);
- queriesInFlight.incrementAndGet();
- service.sendIqPacket(account, infoRequest, new OnIqPacketReceived() {
- @Override
- public void onIqPacketReceived(Account account, IqPacket infoResponse) {
- if (infoResponse.getType() == IqPacket.TYPE.RESULT) {
- final Room room = IqParser.parseRoom(infoResponse);
- if (room != null) {
- rooms.add(room);
- }
- if (queriesInFlight.decrementAndGet() <= 0) {
- finishDiscoSearch(rooms, query, listener);
- }
- } else {
- queriesInFlight.decrementAndGet();
- }
+ service.sendIqPacket(
+ entry.getValue(),
+ itemsRequest,
+ (account, itemsResponse) -> {
+ if (itemsResponse.getType() == IqPacket.TYPE.RESULT) {
+ final List<Jid> items = IqParser.items(itemsResponse);
+ for (Jid item : items) {
+ IqPacket infoRequest =
+ service.getIqGenerator().queryDiscoInfo(item);
+ queriesInFlight.incrementAndGet();
+ service.sendIqPacket(
+ account,
+ infoRequest,
+ new OnIqPacketReceived() {
+ @Override
+ public void onIqPacketReceived(
+ Account account, IqPacket infoResponse) {
+ if (infoResponse.getType()
+ == IqPacket.TYPE.RESULT) {
+ final Room room =
+ IqParser.parseRoom(infoResponse);
+ if (room != null) {
+ rooms.add(room);
+ }
+ if (queriesInFlight.decrementAndGet() <= 0) {
+ finishDiscoSearch(rooms, query, listener);
+ }
+ } else {
+ queriesInFlight.decrementAndGet();
+ }
+ }
+ });
}
- });
- }
- }
- if (queriesInFlight.decrementAndGet() <= 0) {
- finishDiscoSearch(rooms, query, listener);
- }
- });
+ }
+ if (queriesInFlight.decrementAndGet() <= 0) {
+ finishDiscoSearch(rooms, query, listener);
+ }
+ });
}
}
- private void finishDiscoSearch(List<Room> rooms, String query, OnChannelSearchResultsFound listener) {
+ private void finishDiscoSearch(
+ List<Room> rooms, String query, OnChannelSearchResultsFound listener) {
Collections.sort(rooms);
cache.put(key(Method.LOCAL_SERVER, ""), rooms);
if (query.isEmpty()) {
@@ -241,7 +283,7 @@ public class ChannelDiscoveryService {
try {
Log.d(Config.LOGTAG, "error body=" + errorBody.string());
} catch (IOException e) {
- //ignored
+ // ignored
}
}
@@ -1,5 +1,7 @@
package eu.siacs.conversations.services;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
import android.util.Log;
import org.jetbrains.annotations.NotNull;
@@ -502,7 +504,7 @@ public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
this.start = start.getTimestamp();
}
this.end = end;
- this.queryId = new BigInteger(50, mXmppConnectionService.getRNG()).toString(32);
+ this.queryId = new BigInteger(50, SECURE_RANDOM).toString(32);
this.version = version;
}
@@ -42,6 +42,7 @@ import androidx.core.app.RemoteInput;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.IconCompat;
+import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
@@ -104,12 +105,13 @@ public class NotificationService {
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 static final int DELIVERY_FAILED_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 12;
- public static final int MISSED_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 14;
+ public static final int MISSED_CALL_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 12;
+ private static final int DELIVERY_FAILED_NOTIFICATION_ID = NOTIFICATION_ID_MULTIPLIER * 13;
private final XmppConnectionService mXmppConnectionService;
private final LinkedHashMap<String, ArrayList<Message>> notifications = new LinkedHashMap<>();
private final HashMap<Conversation, AtomicInteger> mBacklogMessageCounter = new HashMap<>();
- private final LinkedHashMap<Conversational, MissedCallsInfo> mMissedCalls = new LinkedHashMap<>();
+ private final LinkedHashMap<Conversational, MissedCallsInfo> mMissedCalls =
+ new LinkedHashMap<>();
private Conversation mOpenConversation;
private boolean mIsInForeground;
private long mLastNotification;
@@ -230,9 +232,11 @@ public class NotificationService {
ongoingCallsChannel.setGroup("calls");
notificationManager.createNotificationChannel(ongoingCallsChannel);
- final NotificationChannel missedCallsChannel = new NotificationChannel("missed_calls",
- c.getString(R.string.missed_calls_channel_name),
- NotificationManager.IMPORTANCE_HIGH);
+ final NotificationChannel missedCallsChannel =
+ new NotificationChannel(
+ "missed_calls",
+ c.getString(R.string.missed_calls_channel_name),
+ NotificationManager.IMPORTANCE_HIGH);
missedCallsChannel.setShowBadge(true);
missedCallsChannel.setSound(null, null);
missedCallsChannel.setLightColor(LED_COLOR);
@@ -419,8 +423,8 @@ public class NotificationService {
return count;
}
- void finishBacklog(boolean notify) {
- finishBacklog(notify, null);
+ void finishBacklog() {
+ finishBacklog(false, null);
}
private void pushToStack(final Message message) {
@@ -927,7 +931,8 @@ public class NotificationService {
singleBuilder.setGroupAlertBehavior(
NotificationCompat.GROUP_ALERT_SUMMARY);
}
- modifyForSoundVibrationAndLight(singleBuilder, notifyThis, quiteHours, preferences);
+ modifyForSoundVibrationAndLight(
+ singleBuilder, notifyThis, quiteHours, preferences);
singleBuilder.setGroup(MESSAGES_GROUP);
setNotificationColor(singleBuilder);
notify(entry.getKey(), NOTIFICATION_ID, singleBuilder.build());
@@ -1031,30 +1036,39 @@ public class NotificationService {
}
private Builder buildMissedCallsSummary(boolean publicVersion) {
- final Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
+ final Builder builder =
+ new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
int totalCalls = 0;
- final StringBuilder names = new StringBuilder();
+ final List<String> names = new ArrayList<>();
long lastTime = 0;
- for (Map.Entry<Conversational, MissedCallsInfo> entry : mMissedCalls.entrySet()) {
+ for (final Map.Entry<Conversational, MissedCallsInfo> entry : mMissedCalls.entrySet()) {
final Conversational conversation = entry.getKey();
final MissedCallsInfo missedCallsInfo = entry.getValue();
- names.append(conversation.getContact().getDisplayName());
- names.append(", ");
+ names.add(conversation.getContact().getDisplayName());
totalCalls += missedCallsInfo.getNumberOfCalls();
lastTime = Math.max(lastTime, missedCallsInfo.getLastTime());
}
- if (names.length() >= 2) {
- names.delete(names.length() - 2, names.length());
- }
- final String title = (totalCalls == 1) ? mXmppConnectionService.getString(R.string.missed_call) :
- (mMissedCalls.size() == 1) ? mXmppConnectionService.getString(R.string.n_missed_calls, totalCalls) :
- mXmppConnectionService.getString(R.string.n_missed_calls_from_m_contacts, totalCalls, mMissedCalls.size());
+ final String title =
+ (totalCalls == 1)
+ ? mXmppConnectionService.getString(R.string.missed_call)
+ : (mMissedCalls.size() == 1)
+ ? mXmppConnectionService
+ .getResources()
+ .getQuantityString(
+ R.plurals.n_missed_calls, totalCalls, totalCalls)
+ : mXmppConnectionService
+ .getResources()
+ .getQuantityString(
+ R.plurals.n_missed_calls_from_m_contacts,
+ mMissedCalls.size(),
+ totalCalls,
+ mMissedCalls.size());
builder.setContentTitle(title);
builder.setTicker(title);
if (!publicVersion) {
- builder.setContentText(names.toString());
+ builder.setContentText(Joiner.on(", ").join(names));
}
- builder.setSmallIcon(R.drawable.ic_missed_call_notification);
+ builder.setSmallIcon(R.drawable.ic_call_missed_white_24db);
builder.setGroupSummary(true);
builder.setGroup(MISSED_CALLS_GROUP);
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN);
@@ -1076,38 +1090,55 @@ public class NotificationService {
return builder.build();
}
- private Builder buildMissedCall(final Conversational conversation, final MissedCallsInfo info, boolean publicVersion) {
- final Builder builder = new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
- final String title = (info.getNumberOfCalls() == 1) ? mXmppConnectionService.getString(R.string.missed_call) :
- mXmppConnectionService.getString(R.string.n_missed_calls, info.getNumberOfCalls());
+ private Builder buildMissedCall(
+ final Conversational conversation, final MissedCallsInfo info, boolean publicVersion) {
+ final Builder builder =
+ new NotificationCompat.Builder(mXmppConnectionService, "missed_calls");
+ final String title =
+ (info.getNumberOfCalls() == 1)
+ ? mXmppConnectionService.getString(R.string.missed_call)
+ : mXmppConnectionService
+ .getResources()
+ .getQuantityString(
+ R.plurals.n_missed_calls,
+ info.getNumberOfCalls(),
+ info.getNumberOfCalls());
builder.setContentTitle(title);
final String name = conversation.getContact().getDisplayName();
if (publicVersion) {
builder.setTicker(title);
} else {
- if (info.getNumberOfCalls() == 1) {
- builder.setTicker(mXmppConnectionService.getString(R.string.missed_call_from_x, name));
- } else {
- builder.setTicker(mXmppConnectionService.getString(R.string.n_missed_calls_from_x, info.getNumberOfCalls(), name));
- }
+ builder.setTicker(
+ mXmppConnectionService
+ .getResources()
+ .getQuantityString(
+ R.plurals.n_missed_calls_from_x,
+ info.getNumberOfCalls(),
+ info.getNumberOfCalls(),
+ name));
builder.setContentText(name);
}
- builder.setSmallIcon(R.drawable.ic_missed_call_notification);
+ builder.setSmallIcon(R.drawable.ic_call_missed_white_24db);
builder.setGroup(MISSED_CALLS_GROUP);
builder.setCategory(NotificationCompat.CATEGORY_CALL);
builder.setWhen(info.getLastTime());
builder.setContentIntent(createContentIntent(conversation));
builder.setDeleteIntent(createMissedCallsDeleteIntent(conversation));
if (!publicVersion && conversation instanceof Conversation) {
- builder.setLargeIcon(mXmppConnectionService.getAvatarService()
- .get((Conversation) conversation, AvatarService.getSystemUiAvatarSize(mXmppConnectionService)));
+ builder.setLargeIcon(
+ mXmppConnectionService
+ .getAvatarService()
+ .get(
+ (Conversation) conversation,
+ AvatarService.getSystemUiAvatarSize(mXmppConnectionService)));
}
modifyMissedCall(builder);
return builder;
}
private void modifyMissedCall(final Builder builder) {
- final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mXmppConnectionService);
+ final SharedPreferences preferences =
+ PreferenceManager.getDefaultSharedPreferences(mXmppConnectionService);
final Resources resources = mXmppConnectionService.getResources();
final boolean led = preferences.getBoolean("led", resources.getBoolean(R.bool.led));
if (led) {
@@ -1131,42 +1162,39 @@ public class NotificationService {
R.plurals.x_unread_conversations,
notifications.size(),
notifications.size()));
- final StringBuilder names = new StringBuilder();
+ final List<String> names = new ArrayList<>();
Conversation conversation = null;
for (final ArrayList<Message> messages : notifications.values()) {
- if (messages.size() > 0) {
- conversation = (Conversation) messages.get(0).getConversation();
- final String name = conversation.getName().toString();
- SpannableString styledString;
- if (Config.HIDE_MESSAGE_TEXT_IN_NOTIFICATION) {
- int count = messages.size();
- styledString =
- new SpannableString(
- name
- + ": "
- + mXmppConnectionService
- .getResources()
- .getQuantityString(
- R.plurals.x_messages, count, count));
- styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
- style.addLine(styledString);
- } else {
- styledString =
- new SpannableString(
- name
- + ": "
- + UIHelper.getMessagePreview(
- mXmppConnectionService, messages.get(0))
- .first);
- styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
- style.addLine(styledString);
- }
- names.append(name);
- names.append(", ");
+ if (messages.isEmpty()) {
+ continue;
}
- }
- if (names.length() >= 2) {
- names.delete(names.length() - 2, names.length());
+ conversation = (Conversation) messages.get(0).getConversation();
+ final String name = conversation.getName().toString();
+ SpannableString styledString;
+ if (Config.HIDE_MESSAGE_TEXT_IN_NOTIFICATION) {
+ int count = messages.size();
+ styledString =
+ new SpannableString(
+ name
+ + ": "
+ + mXmppConnectionService
+ .getResources()
+ .getQuantityString(
+ R.plurals.x_messages, count, count));
+ styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
+ style.addLine(styledString);
+ } else {
+ styledString =
+ new SpannableString(
+ name
+ + ": "
+ + UIHelper.getMessagePreview(
+ mXmppConnectionService, messages.get(0))
+ .first);
+ styledString.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), 0);
+ style.addLine(styledString);
+ }
+ names.add(name);
}
final String contentTitle =
mXmppConnectionService
@@ -1177,7 +1205,7 @@ public class NotificationService {
notifications.size());
mBuilder.setContentTitle(contentTitle);
mBuilder.setTicker(contentTitle);
- mBuilder.setContentText(names.toString());
+ mBuilder.setContentText(Joiner.on(", ").join(names));
mBuilder.setStyle(style);
if (conversation != null) {
mBuilder.setContentIntent(createContentIntent(conversation));
@@ -1582,7 +1610,7 @@ public class NotificationService {
return createContentIntent(conversation.getUuid(), null);
}
- private PendingIntent createDeleteIntent(Conversation conversation) {
+ private PendingIntent createDeleteIntent(final Conversation conversation) {
final Intent intent = new Intent(mXmppConnectionService, XmppConnectionService.class);
intent.setAction(XmppConnectionService.ACTION_CLEAR_MESSAGE_NOTIFICATION);
if (conversation != null) {
@@ -1609,11 +1637,21 @@ public class NotificationService {
intent.setAction(XmppConnectionService.ACTION_CLEAR_MISSED_CALL_NOTIFICATION);
if (conversation != null) {
intent.putExtra("uuid", conversation.getUuid());
- return PendingIntent.getService(mXmppConnectionService, generateRequestCode(conversation, 21), intent,
- s() ? PendingIntent.FLAG_IMMUTABLE : 0);
+ return PendingIntent.getService(
+ mXmppConnectionService,
+ generateRequestCode(conversation, 21),
+ intent,
+ s()
+ ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ : PendingIntent.FLAG_UPDATE_CURRENT);
}
- return PendingIntent.getService(mXmppConnectionService, 1, intent,
- s() ? PendingIntent.FLAG_IMMUTABLE : 0);
+ return PendingIntent.getService(
+ mXmppConnectionService,
+ 1,
+ intent,
+ s()
+ ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ : PendingIntent.FLAG_UPDATE_CURRENT);
}
private PendingIntent createReplyIntent(
@@ -1937,16 +1975,6 @@ public class NotificationService {
}
}
- private class VibrationRunnable implements Runnable {
-
- @Override
- public void run() {
- final Vibrator vibrator =
- (Vibrator) mXmppConnectionService.getSystemService(Context.VIBRATOR_SERVICE);
- vibrator.vibrate(CALL_PATTERN, -1);
- }
- }
-
private static class MissedCallsInfo {
private int numberOfCalls;
private long lastTime;
@@ -1969,4 +1997,14 @@ public class NotificationService {
return lastTime;
}
}
+
+ private class VibrationRunnable implements Runnable {
+
+ @Override
+ public void run() {
+ final Vibrator vibrator =
+ (Vibrator) mXmppConnectionService.getSystemService(Context.VIBRATOR_SERVICE);
+ vibrator.vibrate(CALL_PATTERN, -1);
+ }
+ }
}
@@ -1,6 +1,7 @@
package eu.siacs.conversations.services;
import static eu.siacs.conversations.utils.Compatibility.s;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
import android.Manifest;
import android.annotation.SuppressLint;
@@ -40,7 +41,6 @@ import android.preference.PreferenceManager;
import android.provider.ContactsContract;
import android.security.KeyChain;
import android.telephony.PhoneStateListener;
-import android.telephony.TelephonyCallback;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.util.DisplayMetrics;
@@ -50,6 +50,7 @@ import android.util.Pair;
import androidx.annotation.BoolRes;
import androidx.annotation.IntegerRes;
+import androidx.annotation.NonNull;
import androidx.core.app.RemoteInput;
import androidx.core.content.ContextCompat;
@@ -392,7 +393,6 @@ public class XmppConnectionService extends Service {
}
};
private final AtomicLong mLastExpiryRun = new AtomicLong(0);
- private SecureRandom mRandom;
private final LruCache<Pair<String, String>, ServiceDiscoveryResult> discoCache = new LruCache<>(20);
private final OnStatusChanged statusListener = new OnStatusChanged() {
@@ -464,7 +464,7 @@ public class XmppConnectionService extends Service {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": went into offline state during low ping mode. reconnecting now");
reconnectAccount(account, true, false);
} else {
- int timeToReconnect = mRandom.nextInt(10) + 2;
+ final int timeToReconnect = SECURE_RANDOM.nextInt(10) + 2;
scheduleWakeUpCall(timeToReconnect, account.getUuid().hashCode());
}
} else if (account.getStatus() == Account.State.REGISTRATION_SUCCESSFUL) {
@@ -967,9 +967,11 @@ public class XmppConnectionService extends Service {
public boolean isDataSaverDisabled() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
+ final ConnectivityManager connectivityManager =
+ (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
return !connectivityManager.isActiveNetworkMetered()
- || connectivityManager.getRestrictBackgroundStatus() == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
+ || Compatibility.getRestrictBackgroundStatus(connectivityManager)
+ == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
} else {
return true;
}
@@ -1164,7 +1166,6 @@ public class XmppConnectionService extends Service {
Log.e(Config.LOGTAG, "unable to initialize security provider", throwable);
}
Resolver.init(this);
- this.mRandom = new SecureRandom();
updateMemorizingTrustmanager();
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
final int cacheSize = maxMemory / 9;
@@ -1900,7 +1901,7 @@ public class XmppConnectionService extends Service {
IqPacket iqPacket = new IqPacket(IqPacket.TYPE.SET);
Element query = iqPacket.query("jabber:iq:private");
Element storage = query.addChild("storage", "storage:bookmarks");
- for (Bookmark bookmark : account.getBookmarks()) {
+ for (final Bookmark bookmark : account.getBookmarks()) {
storage.addChild(bookmark);
}
sendIqPacket(account, iqPacket, mDefaultIqHandler);
@@ -1910,8 +1911,8 @@ public class XmppConnectionService extends Service {
if (!account.areBookmarksLoaded()) return;
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": pushing bookmarks via pep");
- Element storage = new Element("storage", "storage:bookmarks");
- for (Bookmark bookmark : account.getBookmarks()) {
+ final Element storage = new Element("storage", "storage:bookmarks");
+ for (final Bookmark bookmark : account.getBookmarks()) {
storage.addChild(bookmark);
}
pushNodeAndEnforcePublishOptions(account, Namespace.BOOKMARKS, storage, "current", PublishOptions.persistentWhitelistAccess());
@@ -1979,7 +1980,7 @@ public class XmppConnectionService extends Service {
databaseBackend.expireOldMessages(deletionDate);
}
Log.d(Config.LOGTAG, "restoring roster...");
- for (Account account : accounts) {
+ for (final Account account : accounts) {
databaseBackend.readRoster(account.getRoster());
account.initAccountServices(XmppConnectionService.this); //roster needs to be loaded at this stage
}
@@ -1999,7 +2000,7 @@ public class XmppConnectionService extends Service {
restoreMessages(conversation);
}
}
- mNotificationService.finishBacklog(false);
+ mNotificationService.finishBacklog();
restoredFromDatabaseLatch.countDown();
final long diffMessageRestore = SystemClock.elapsedRealtime() - startMessageRestore;
Log.d(Config.LOGTAG, "finished restoring messages in " + diffMessageRestore + "ms");
@@ -2017,11 +2018,11 @@ public class XmppConnectionService extends Service {
public void loadPhoneContacts() {
mContactMergerExecutor.execute(() -> {
- Map<Jid, JabberIdContact> contacts = JabberIdContact.load(this);
+ final Map<Jid, JabberIdContact> contacts = JabberIdContact.load(this);
Log.d(Config.LOGTAG, "start merging phone contacts with roster");
- for (Account account : accounts) {
- List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(JabberIdContact.class);
- for (JabberIdContact jidContact : contacts.values()) {
+ for (final Account account : accounts) {
+ final List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(JabberIdContact.class);
+ for (final JabberIdContact jidContact : contacts.values()) {
final Contact contact = account.getRoster().getContact(jidContact.getJid());
boolean needsCacheClean = contact.setPhoneContact(jidContact);
if (needsCacheClean) {
@@ -2029,7 +2030,7 @@ public class XmppConnectionService extends Service {
}
withSystemAccounts.remove(contact);
}
- for (Contact contact : withSystemAccounts) {
+ for (final Contact contact : withSystemAccounts) {
boolean needsCacheClean = contact.unsetPhoneContact(JabberIdContact.class);
if (needsCacheClean) {
getAvatarService().clear(contact);
@@ -2859,7 +2860,6 @@ public class XmppConnectionService extends Service {
}
});
}
-
public void joinMuc(Conversation conversation) {
joinMuc(conversation, null, false);
}
@@ -3077,6 +3077,71 @@ public class XmppConnectionService extends Service {
}
}
+ public void deleteAvatar(final Account account) {
+ final AtomicBoolean executed = new AtomicBoolean(false);
+ final Runnable onDeleted =
+ () -> {
+ if (executed.compareAndSet(false, true)) {
+ account.setAvatar(null);
+ databaseBackend.updateAccount(account);
+ getAvatarService().clear(account);
+ updateAccountUi();
+ }
+ };
+ deleteVcardAvatar(account, onDeleted);
+ deletePepNode(account, Namespace.AVATAR_DATA);
+ deletePepNode(account, Namespace.AVATAR_METADATA, onDeleted);
+ }
+
+ public void deletePepNode(final Account account, final String node) {
+ deletePepNode(account, node, null);
+ }
+
+ private void deletePepNode(final Account account, final String node, final Runnable runnable) {
+ final IqPacket request = mIqGenerator.deleteNode(node);
+ sendIqPacket(account, request, (a, packet) -> {
+ if (packet.getType() == IqPacket.TYPE.RESULT) {
+ Log.d(Config.LOGTAG,a.getJid().asBareJid()+": successfully deleted pep node "+node);
+ if (runnable != null) {
+ runnable.run();
+ }
+ } else {
+ Log.d(Config.LOGTAG,a.getJid().asBareJid()+": failed to delete "+ packet);
+ }
+ });
+ }
+
+ private void deleteVcardAvatar(final Account account, @NonNull final Runnable runnable) {
+ final IqPacket retrieveVcard = mIqGenerator.retrieveVcardAvatar(account.getJid().asBareJid());
+ sendIqPacket(account, retrieveVcard, (a, response) -> {
+ if (response.getType() != IqPacket.TYPE.RESULT) {
+ Log.d(Config.LOGTAG,a.getJid().asBareJid()+": no vCard set. nothing to do");
+ return;
+ }
+ final Element vcard = response.findChild("vCard", "vcard-temp");
+ if (vcard == null) {
+ Log.d(Config.LOGTAG,a.getJid().asBareJid()+": no vCard set. nothing to do");
+ return;
+ }
+ Element photo = vcard.findChild("PHOTO");
+ if (photo == null) {
+ photo = vcard.addChild("PHOTO");
+ }
+ photo.clearChildren();
+ IqPacket publication = new IqPacket(IqPacket.TYPE.SET);
+ publication.setTo(a.getJid().asBareJid());
+ publication.addChild(vcard);
+ sendIqPacket(account, publication, (a1, publicationResponse) -> {
+ if (publicationResponse.getType() == IqPacket.TYPE.RESULT) {
+ Log.d(Config.LOGTAG,a1.getJid().asBareJid()+": successfully deleted vcard avatar");
+ runnable.run();
+ } else {
+ Log.d(Config.LOGTAG, "failed to publish vcard " + publicationResponse.getErrorCondition());
+ }
+ });
+ });
+ }
+
private boolean hasEnabledAccounts() {
if (this.accounts == null) {
return false;
@@ -3253,7 +3318,7 @@ public class XmppConnectionService extends Service {
}
return false;
}
- final Jid jid = Jid.of(CryptoHelper.pronounceable(getRNG()), server, null);
+ final Jid jid = Jid.of(CryptoHelper.pronounceable(), server, null);
final Conversation conversation = findOrCreateConversation(account, jid, true, false, true);
joinMuc(conversation, new OnConferenceJoined() {
@Override
@@ -3673,7 +3738,7 @@ public class XmppConnectionService extends Service {
if (result.getType() == IqPacket.TYPE.RESULT) {
publishAvatarMetadata(account, avatar, options, true, callback);
} else if (retry && PublishOptions.preconditionNotMet(result)) {
- pushNodeConfiguration(account, "urn:xmpp:avatar:data", options, new OnConfigurationPushed() {
+ pushNodeConfiguration(account, Namespace.AVATAR_DATA, options, new OnConfigurationPushed() {
@Override
public void onPushSucceeded() {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": changed node configuration for avatar node");
@@ -3713,7 +3778,7 @@ public class XmppConnectionService extends Service {
callback.onAvatarPublicationSucceeded();
}
} else if (retry && PublishOptions.preconditionNotMet(result)) {
- pushNodeConfiguration(account, "urn:xmpp:avatar:metadata", options, new OnConfigurationPushed() {
+ pushNodeConfiguration(account, Namespace.AVATAR_METADATA, options, new OnConfigurationPushed() {
@Override
public void onPushSucceeded() {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": changed node configuration for avatar meta data node");
@@ -4358,10 +4423,6 @@ public class XmppConnectionService extends Service {
}
}
- public SecureRandom getRNG() {
- return this.mRandom;
- }
-
public MemorizingTrustManager getMemorizingTrustManager() {
return this.mMemorizingTrustManager;
}
@@ -4419,7 +4480,7 @@ public class XmppConnectionService extends Service {
for (final Account account : accounts) {
if (account.getXmppConnection() != null) {
mucServers.addAll(account.getXmppConnection().getMucServers());
- for (Bookmark bookmark : account.getBookmarks()) {
+ for (final Bookmark bookmark : account.getBookmarks()) {
final Jid jid = bookmark.getJid();
final String s = jid == null ? null : jid.getDomain().toEscapedString();
if (s != null) {
@@ -4500,7 +4561,6 @@ public class XmppConnectionService extends Service {
for (Account account : getAccounts()) {
if (account.isOnlineAndConnected() && mPushManagementService.available(account)) {
mPushManagementService.registerPushTokenOnServer(account);
- //TODO renew mucs
}
}
}
@@ -20,6 +20,8 @@ import android.widget.Toast;
import androidx.databinding.DataBindingUtil;
+import com.google.common.base.Strings;
+
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -90,6 +92,9 @@ public class ChannelDiscoveryActivity extends XmppActivity implements MenuItem.O
}
private static ChannelDiscoveryService.Method getMethod(final Context c) {
+ if ( Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
+ return ChannelDiscoveryService.Method.LOCAL_SERVER;
+ }
if (QuickConversationsService.isQuicksy()) {
return ChannelDiscoveryService.Method.JABBER_NETWORK;
}
@@ -2801,6 +2801,9 @@ public class ConversationFragment extends XmppFragment
case KICKED:
showSnackbar(R.string.conference_kicked, R.string.join, joinMuc);
break;
+ case TECHNICAL_PROBLEMS:
+ showSnackbar(R.string.conference_technical_problems, R.string.try_again, joinMuc);
+ break;
case UNKNOWN:
showSnackbar(R.string.conference_unknown_error, R.string.try_again, joinMuc);
break;
@@ -43,7 +43,6 @@ public class CreatePublicChannelDialog extends DialogFragment implements OnBacke
private boolean jidWasModified = false;
private boolean nameEntered = false;
private boolean skipTetxWatcher = false;
- private static final SecureRandom RANDOM = new SecureRandom();
public static CreatePublicChannelDialog newInstance(List<String> accounts) {
CreatePublicChannelDialog dialog = new CreatePublicChannelDialog();
@@ -158,7 +157,7 @@ public class CreatePublicChannelDialog extends DialogFragment implements OnBacke
try {
return Jid.of(localpart, domain, null).toEscapedString();
} catch (IllegalArgumentException e) {
- return Jid.of(CryptoHelper.pronounceable(RANDOM), domain, null).toEscapedString();
+ return Jid.of(CryptoHelper.pronounceable(), domain, null).toEscapedString();
}
}
}
@@ -181,7 +181,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
}
if (inNeedOfSaslAccept()) {
- mAccount.setKey(Account.PINNED_MECHANISM_KEY, String.valueOf(-1));
+ mAccount.resetPinnedMechanism();
if (!xmppConnectionService.updateAccount(mAccount)) {
Toast.makeText(EditAccountActivity.this, R.string.unable_to_update_account, Toast.LENGTH_SHORT).show();
}
@@ -286,13 +286,14 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
mAccount = new Account(jid.asBareJid(), password);
mAccount.setPort(numericPort);
mAccount.setHostname(hostname);
- mAccount.setOption(Account.OPTION_USETLS, true);
- mAccount.setOption(Account.OPTION_USECOMPRESSION, true);
mAccount.setOption(Account.OPTION_REGISTER, registerNewAccount);
xmppConnectionService.createAccount(mAccount);
}
binding.hostnameLayout.setError(null);
binding.portLayout.setError(null);
+ if (mAccount.isOnion()) {
+ Toast.makeText(EditAccountActivity.this, R.string.audio_video_disabled_tor, Toast.LENGTH_LONG).show();
+ }
if (mAccount.isEnabled()
&& !registerNewAccount
&& !mInitMode) {
@@ -418,7 +419,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
} else {
preset = jid.getDomain();
}
- final Intent intent = SignupUtils.getTokenRegistrationIntent(this, preset, mAccount.getKey(Account.PRE_AUTH_REGISTRATION_TOKEN));
+ final Intent intent = SignupUtils.getTokenRegistrationIntent(this, preset, mAccount.getKey(Account.KEY_PRE_AUTH_REGISTRATION_TOKEN));
StartConversationActivity.addInviteUri(intent, getIntent());
startActivity(intent);
return;
@@ -824,7 +825,6 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
}
if (mUsernameMode) {
this.binding.accountJidLayout.setHint(getString(R.string.username_hint));
- this.binding.accountJid.setHint(R.string.username_hint);
} else {
final KnownHostsAdapter mKnownHostsAdapter = new KnownHostsAdapter(this,
R.layout.simple_list_item,
@@ -892,7 +892,7 @@ public class EditAccountActivity extends OmemoActivity implements OnAccountUpdat
}
private boolean inNeedOfSaslAccept() {
- return mAccount != null && mAccount.getLastErrorStatus() == Account.State.DOWNGRADE_ATTACK && mAccount.getKeyAsInt(Account.PINNED_MECHANISM_KEY, -1) >= 0 && !accountInfoEdited();
+ return mAccount != null && mAccount.getLastErrorStatus() == Account.State.DOWNGRADE_ATTACK && mAccount.getPinnedMechanismPriority() >= 0 && !accountInfoEdited();
}
private void shareBarcode() {
@@ -284,14 +284,14 @@ public class EnterJidDialog extends DialogFragment implements OnBackendConnected
}
if (p == null) {
- finish.onGatewayResult(binding.jid.getText().toString(), null);
+ finish.onGatewayResult(binding.jid.getText().toString().trim(), null);
} else if (p.first != null) { // Gateway already responsed to jabber:iq:gateway once
final Account acct = ((XmppActivity) getActivity()).xmppConnectionService.findAccountByJid(accountJid);
- ((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString(), finish);
+ ((XmppActivity) getActivity()).xmppConnectionService.fetchFromGateway(acct, p.second.first, binding.jid.getText().toString().trim(), finish);
} else if (p.second.first.isDomainJid() && p.second.second.getServiceDiscoveryResult().getFeatures().contains("jid\\20escaping")) {
- finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString(), p.second.first.getDomain().toString()).toString(), null);
+ finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().trim(), p.second.first.getDomain().toString()).toString(), null);
} else if (p.second.first.isDomainJid()) {
- finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
+ finish.onGatewayResult(Jid.ofLocalAndDomain(binding.jid.getText().toString().trim().replace("@", "%"), p.second.first.getDomain().toString()).toString(), null);
} else {
finish.onGatewayResult(null, null);
}
@@ -7,6 +7,8 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
import android.view.View;
import android.view.View.OnLongClickListener;
import android.widget.Button;
@@ -14,6 +16,7 @@ import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
+import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import com.theartofdev.edmodo.cropper.CropImage;
@@ -99,18 +102,25 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
xmppConnectionService.publishAvatar(account, avatarUri, this);
}
});
- this.cancelButton.setOnClickListener(v -> {
- if (mInitialAccountSetup) {
- final Intent intent = new Intent(getApplicationContext(), StartConversationActivity.class);
- if (xmppConnectionService != null && xmppConnectionService.getAccounts().size() == 1) {
- intent.putExtra("init", true);
- }
- StartConversationActivity.addInviteUri(intent, getIntent());
- intent.putExtra(EXTRA_ACCOUNT, account.getJid().asBareJid().toEscapedString());
- startActivity(intent);
- }
- finish();
- });
+ this.cancelButton.setOnClickListener(
+ v -> {
+ if (mInitialAccountSetup) {
+ final Intent intent =
+ new Intent(
+ getApplicationContext(), StartConversationActivity.class);
+ if (xmppConnectionService != null
+ && xmppConnectionService.getAccounts().size() == 1) {
+ intent.putExtra("init", true);
+ }
+ StartConversationActivity.addInviteUri(intent, getIntent());
+ if (account != null) {
+ intent.putExtra(
+ EXTRA_ACCOUNT, account.getJid().asBareJid().toEscapedString());
+ }
+ startActivity(intent);
+ }
+ finish();
+ });
this.avatar.setOnClickListener(v -> chooseAvatar(this));
this.defaultUri = PhoneHelper.getProfilePictureUri(getApplicationContext());
if (savedInstanceState != null) {
@@ -120,7 +130,25 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
}
@Override
- public void onSaveInstanceState(Bundle outState) {
+ public boolean onCreateOptionsMenu(@NonNull final Menu menu) {
+ getMenuInflater().inflate(R.menu.activity_publish_profile_picture, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (item.getItemId() == R.id.action_delete_avatar) {
+ if (xmppConnectionService != null && account != null) {
+ xmppConnectionService.deleteAvatar(account);
+ }
+ return true;
+ } else {
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
if (this.avatarUri != null) {
outState.putParcelable("uri", this.avatarUri);
}
@@ -18,6 +18,7 @@ import android.widget.Toast;
import androidx.databinding.DataBindingUtil;
import java.io.File;
+import java.lang.ref.WeakReference;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
@@ -136,30 +137,41 @@ public class RecordingActivity extends Activity implements View.OnClickListener
}
}
if (saveFile) {
- new Thread(
- () -> {
- try {
- if (!outputFileWrittenLatch.await(2, TimeUnit.SECONDS)) {
- Log.d(
- Config.LOGTAG,
- "time out waiting for output file to be written");
- }
- } catch (InterruptedException e) {
- Log.d(
- Config.LOGTAG,
- "interrupted while waiting for output file to be written",
- e);
- }
- runOnUiThread(
- () -> {
- setResult(
- Activity.RESULT_OK,
- new Intent()
- .setData(Uri.fromFile(mOutputFile)));
- finish();
- });
- })
- .start();
+ new Thread(new Finisher(outputFileWrittenLatch, mOutputFile, this)).start();
+ }
+ }
+
+ private static class Finisher implements Runnable {
+
+ private final CountDownLatch latch;
+ private final File outputFile;
+ private final WeakReference<Activity> activityReference;
+
+ private Finisher(CountDownLatch latch, File outputFile, Activity activity) {
+ this.latch = latch;
+ this.outputFile = outputFile;
+ this.activityReference = new WeakReference<>(activity);
+ }
+
+ @Override
+ public void run() {
+ try {
+ if (!latch.await(8, TimeUnit.SECONDS)) {
+ Log.d(Config.LOGTAG, "time out waiting for output file to be written");
+ }
+ } catch (final InterruptedException e) {
+ Log.d(Config.LOGTAG, "interrupted while waiting for output file to be written", e);
+ }
+ final Activity activity = activityReference.get();
+ if (activity == null) {
+ return;
+ }
+ activity.runOnUiThread(
+ () -> {
+ activity.setResult(
+ Activity.RESULT_OK, new Intent().setData(Uri.fromFile(outputFile)));
+ activity.finish();
+ });
}
}
@@ -187,7 +199,7 @@ public class RecordingActivity extends Activity implements View.OnClickListener
setupFileObserver(parentDirectory);
}
- private void setupFileObserver(File directory) {
+ private void setupFileObserver(final File directory) {
mFileObserver =
new FileObserver(directory.getAbsolutePath()) {
@Override
@@ -207,7 +219,7 @@ public class RecordingActivity extends Activity implements View.OnClickListener
}
@Override
- public void onClick(View view) {
+ public void onClick(final View view) {
switch (view.getId()) {
case R.id.cancel_button:
mHandler.removeCallbacks(mTickExecutor);
@@ -5,6 +5,7 @@ import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
import android.Manifest;
import android.annotation.SuppressLint;
+import android.app.Activity;
import android.app.PictureInPictureParams;
import android.content.ActivityNotFoundException;
import android.content.Context;
@@ -66,6 +67,7 @@ import eu.siacs.conversations.utils.TimeFrameUtils;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
+import eu.siacs.conversations.xmpp.jingle.ContentAddition;
import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
@@ -102,9 +104,12 @@ public class RtpSessionActivity extends XmppActivity
Arrays.asList(
RtpEndUserState.CONNECTING,
RtpEndUserState.CONNECTED,
- RtpEndUserState.RECONNECTING);
+ RtpEndUserState.RECONNECTING,
+ RtpEndUserState.INCOMING_CONTENT_ADD);
private static final List<RtpEndUserState> STATES_CONSIDERED_CONNECTED =
- Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING);
+ Arrays.asList(
+ RtpEndUserState.CONNECTED,
+ RtpEndUserState.RECONNECTING);
private static final List<RtpEndUserState> STATES_SHOWING_PIP_PLACEHOLDER =
Arrays.asList(
RtpEndUserState.ACCEPTING_CALL,
@@ -112,6 +117,8 @@ public class RtpSessionActivity extends XmppActivity
RtpEndUserState.RECONNECTING);
private static final String PROXIMITY_WAKE_LOCK_TAG = "conversations:in-rtp-session";
private static final int REQUEST_ACCEPT_CALL = 0x1111;
+ private static final int REQUEST_ACCEPT_CONTENT = 0x1112;
+ private static final int REQUEST_ADD_CONTENT = 0x1113;
private WeakReference<JingleRtpConnection> rtpConnectionReference;
private ActivityRtpSessionBinding binding;
@@ -177,8 +184,10 @@ public class RtpSessionActivity extends XmppActivity
final MenuItem help = menu.findItem(R.id.action_help);
final MenuItem gotoChat = menu.findItem(R.id.action_goto_chat);
final MenuItem dialpad = menu.findItem(R.id.action_dialpad);
- help.setVisible(isHelpButtonVisible());
+ final MenuItem switchToVideo = menu.findItem(R.id.action_switch_to_video);
+ help.setVisible(Config.HELP != null && isHelpButtonVisible());
gotoChat.setVisible(isSwitchToConversationVisible());
+ switchToVideo.setVisible(isSwitchToVideoVisible());
dialpad.setVisible(isAudioOnlyConversation());
return super.onCreateOptionsMenu(menu);
}
@@ -224,6 +233,15 @@ public class RtpSessionActivity extends XmppActivity
return connection != null && !connection.getMedia().contains(Media.VIDEO);
}
+ private boolean isSwitchToVideoVisible() {
+ final JingleRtpConnection connection =
+ this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
+ if (connection == null) {
+ return false;
+ }
+ return Media.audioOnly(connection.getMedia()) && STATES_CONSIDERED_CONNECTED.contains(connection.getEndUserState());
+ }
+
private void switchToConversation() {
final Contact contact = getWith();
final Conversation conversation =
@@ -245,13 +263,16 @@ public class RtpSessionActivity extends XmppActivity
switch (item.getItemId()) {
case R.id.action_help:
launchHelpInBrowser();
- break;
+ return true;
case R.id.action_goto_chat:
switchToConversation();
- break;
+ return true;
case R.id.action_dialpad:
toggleDialpadVisibility();
- break;
+ return true;
+ case R.id.action_switch_to_video:
+ requestPermissionAndSwitchToVideo();
+ return true;
}
return super.onOptionsItemSelected(item);
}
@@ -305,9 +326,60 @@ public class RtpSessionActivity extends XmppActivity
requestPermissionsAndAcceptCall();
}
+ private void acceptContentAdd() {
+ try {
+ requireRtpConnection()
+ .acceptContentAdd(requireRtpConnection().getPendingContentAddition().summary);
+ } catch (final IllegalStateException e) {
+ Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private void requestPermissionAndSwitchToVideo() {
+ final List<String> permissions = permissions(ImmutableSet.of(Media.VIDEO, Media.AUDIO));
+ if (PermissionUtils.hasPermission(this, permissions, REQUEST_ADD_CONTENT)) {
+ switchToVideo();
+ }
+ }
+
+ private void switchToVideo() {
+ try {
+ requireRtpConnection().addMedia(Media.VIDEO);
+ } catch (final IllegalStateException e) {
+ Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private void acceptContentAdd(final ContentAddition contentAddition) {
+ if (contentAddition == null || contentAddition.direction != ContentAddition.Direction.INCOMING) {
+ Log.d(Config.LOGTAG,"ignore press on content-accept button");
+ return;
+ }
+ requestPermissionAndAcceptContentAdd(contentAddition);
+ }
+
+ private void requestPermissionAndAcceptContentAdd(final ContentAddition contentAddition) {
+ final List<String> permissions = permissions(contentAddition.media());
+ if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CONTENT)) {
+ requireRtpConnection().acceptContentAdd(contentAddition.summary);
+ }
+ }
+
+ private void rejectContentAdd(final View view) {
+ requireRtpConnection().rejectContentAdd();
+ }
+
private void requestPermissionsAndAcceptCall() {
+ final List<String> permissions = permissions(getMedia());
+ if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CALL)) {
+ putScreenInCallMode();
+ checkRecorderAndAcceptCall();
+ }
+ }
+
+ private List<String> permissions(final Set<Media> media) {
final ImmutableList.Builder<String> permissions = ImmutableList.builder();
- if (getMedia().contains(Media.VIDEO)) {
+ if (media.contains(Media.VIDEO)) {
permissions.add(Manifest.permission.CAMERA).add(Manifest.permission.RECORD_AUDIO);
} else {
permissions.add(Manifest.permission.RECORD_AUDIO);
@@ -315,10 +387,7 @@ public class RtpSessionActivity extends XmppActivity
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions.add(Manifest.permission.BLUETOOTH_CONNECT);
}
- if (PermissionUtils.hasPermission(this, permissions.build(), REQUEST_ACCEPT_CALL)) {
- putScreenInCallMode();
- checkRecorderAndAcceptCall();
- }
+ return permissions.build();
}
private void checkRecorderAndAcceptCall() {
@@ -331,21 +400,38 @@ public class RtpSessionActivity extends XmppActivity
}
private void checkMicrophoneAvailabilityAsync() {
- new Thread(this::checkMicrophoneAvailability).start();
+ new Thread(new MicrophoneAvailabilityCheck(this)).start();
}
- private void checkMicrophoneAvailability() {
- 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;
+ private static class MicrophoneAvailabilityCheck implements Runnable {
+
+ private final WeakReference<Activity> activityReference;
+
+ private MicrophoneAvailabilityCheck(final Activity activity) {
+ this.activityReference = new WeakReference<>(activity);
+ }
+
+ @Override
+ public void run() {
+ 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;
+ }
+ final Activity activity = activityReference.get();
+ if (activity == null) {
+ return;
+ }
+ activity.runOnUiThread(
+ () ->
+ Toast.makeText(
+ activity,
+ R.string.microphone_unavailable,
+ Toast.LENGTH_LONG)
+ .show());
}
- runOnUiThread(
- () ->
- Toast.makeText(this, R.string.microphone_unavailable, Toast.LENGTH_LONG)
- .show());
}
private void putScreenInCallMode() {
@@ -532,11 +618,18 @@ public class RtpSessionActivity extends XmppActivity
if (PermissionUtils.allGranted(permissionResult.grantResults)) {
if (requestCode == REQUEST_ACCEPT_CALL) {
checkRecorderAndAcceptCall();
+ } else if (requestCode == REQUEST_ACCEPT_CONTENT) {
+ acceptContentAdd();
+ } else if (requestCode == REQUEST_ADD_CONTENT) {
+ switchToVideo();
}
} else {
@StringRes int res;
final String firstDenied =
getFirstDenied(permissionResult.grantResults, permissionResult.permissions);
+ if (firstDenied == null) {
+ return;
+ }
if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) {
res = R.string.no_microphone_permission;
} else if (Manifest.permission.CAMERA.equals(firstDenied)) {
@@ -611,8 +704,8 @@ public class RtpSessionActivity extends XmppActivity
private boolean isConnected() {
final JingleRtpConnection connection =
this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
- return connection != null
- && STATES_CONSIDERED_CONNECTED.contains(connection.getEndUserState());
+ final RtpEndUserState endUserState = connection == null ? null : connection.getEndUserState();
+ return STATES_CONSIDERED_CONNECTED.contains(endUserState) || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD;
}
private boolean switchToPictureInPicture() {
@@ -704,6 +797,7 @@ public class RtpSessionActivity extends XmppActivity
return true;
}
final Set<Media> media = getMedia();
+ final ContentAddition contentAddition = getPendingContentAddition();
if (currentState == RtpEndUserState.INCOMING_CALL) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
@@ -713,9 +807,9 @@ public class RtpSessionActivity extends XmppActivity
}
setWith(currentState);
updateVideoViews(currentState);
- updateStateDisplay(currentState, media);
+ updateStateDisplay(currentState, media, contentAddition);
updateVerifiedShield(verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(currentState));
- updateButtonConfiguration(currentState, media);
+ updateButtonConfiguration(currentState, media, contentAddition);
updateIncomingCallScreen(currentState);
invalidateOptionsMenu();
return false;
@@ -766,10 +860,10 @@ public class RtpSessionActivity extends XmppActivity
}
private void updateStateDisplay(final RtpEndUserState state) {
- updateStateDisplay(state, Collections.emptySet());
+ updateStateDisplay(state, Collections.emptySet(), null);
}
- private void updateStateDisplay(final RtpEndUserState state, final Set<Media> media) {
+ private void updateStateDisplay(final RtpEndUserState state, final Set<Media> media, final ContentAddition contentAddition) {
switch (state) {
case INCOMING_CALL:
Preconditions.checkArgument(media.size() > 0, "Media must not be empty");
@@ -779,6 +873,13 @@ public class RtpSessionActivity extends XmppActivity
setTitle(R.string.rtp_state_incoming_call);
}
break;
+ case INCOMING_CONTENT_ADD:
+ if (contentAddition != null && contentAddition.media().contains(Media.VIDEO)) {
+ setTitle(R.string.rtp_state_content_add_video);
+ } else {
+ setTitle(R.string.rtp_state_content_add);
+ }
+ break;
case CONNECTING:
setTitle(R.string.rtp_state_connecting);
break;
@@ -870,12 +971,16 @@ public class RtpSessionActivity extends XmppActivity
return requireRtpConnection().getMedia();
}
+ public ContentAddition getPendingContentAddition() {
+ return requireRtpConnection().getPendingContentAddition();
+ }
+
private void updateButtonConfiguration(final RtpEndUserState state) {
- updateButtonConfiguration(state, Collections.emptySet());
+ updateButtonConfiguration(state, Collections.emptySet(), null);
}
@SuppressLint("RestrictedApi")
- private void updateButtonConfiguration(final RtpEndUserState state, final Set<Media> media) {
+ private void updateButtonConfiguration(final RtpEndUserState state, final Set<Media> media, final ContentAddition contentAddition) {
if (state == RtpEndUserState.ENDING_CALL || isPictureInPicture()) {
this.binding.rejectCall.setVisibility(View.INVISIBLE);
this.binding.endCall.setVisibility(View.INVISIBLE);
@@ -890,6 +995,16 @@ public class RtpSessionActivity extends XmppActivity
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.INCOMING_CONTENT_ADD) {
+ this.binding.rejectCall.setContentDescription(getString(R.string.reject_switch_to_video));
+ this.binding.rejectCall.setOnClickListener(this::rejectContentAdd);
+ 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.setContentDescription(getString(R.string.accept));
+ this.binding.acceptCall.setOnClickListener((v -> acceptContentAdd(contentAddition)));
+ this.binding.acceptCall.setImageResource(R.drawable.ic_baseline_check_24);
+ this.binding.acceptCall.setVisibility(View.VISIBLE);
} else if (state == RtpEndUserState.DECLINED_OR_BUSY) {
this.binding.rejectCall.setContentDescription(getString(R.string.exit));
this.binding.rejectCall.setOnClickListener(this::exit);
@@ -1064,6 +1179,12 @@ public class RtpSessionActivity extends XmppActivity
}
private void disableVideo(View view) {
+ final JingleRtpConnection rtpConnection = requireRtpConnection();
+ final ContentAddition pending = rtpConnection.getPendingContentAddition();
+ if (pending != null && pending.direction == ContentAddition.Direction.OUTGOING) {
+ rtpConnection.retractContentAdd();
+ return;
+ }
requireRtpConnection().setVideoEnabled(false);
updateInCallButtonConfigurationVideo(false, requireRtpConnection().isCameraSwitchable());
}
@@ -1292,6 +1413,7 @@ public class RtpSessionActivity extends XmppActivity
final AbstractJingleConnection.Id id = requireRtpConnection().getId();
final boolean verified = requireRtpConnection().isVerified();
final Set<Media> media = getMedia();
+ final ContentAddition contentAddition = getPendingContentAddition();
final Contact contact = getWith();
if (account == id.account && id.with.equals(with) && id.sessionId.equals(sessionId)) {
if (state == RtpEndUserState.ENDED) {
@@ -1300,10 +1422,10 @@ public class RtpSessionActivity extends XmppActivity
}
runOnUiThread(
() -> {
- updateStateDisplay(state, media);
+ updateStateDisplay(state, media, contentAddition);
updateVerifiedShield(
verified && STATES_SHOWING_SWITCH_TO_CHAT.contains(state));
- updateButtonConfiguration(state, media);
+ updateButtonConfiguration(state, media, contentAddition);
updateVideoViews(state);
updateIncomingCallScreen(state, contact);
invalidateOptionsMenu();
@@ -23,6 +23,8 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
+import com.google.common.base.Strings;
+
import java.io.File;
import java.security.KeyStoreException;
import java.util.ArrayList;
@@ -38,410 +40,489 @@ import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.ExportBackupService;
import eu.siacs.conversations.services.MemorizingTrustManager;
import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.ui.util.SettingsUtils;
import eu.siacs.conversations.ui.util.StyledAttributes;
import eu.siacs.conversations.utils.GeoHelper;
-import eu.siacs.conversations.ui.util.SettingsUtils;
import eu.siacs.conversations.utils.TimeFrameUtils;
import eu.siacs.conversations.xmpp.Jid;
-public class SettingsActivity extends XmppActivity implements
- OnSharedPreferenceChangeListener {
-
- public static final String KEEP_FOREGROUND_SERVICE = "enable_foreground_service";
- public static final String AWAY_WHEN_SCREEN_IS_OFF = "away_when_screen_off";
- public static final String TREAT_VIBRATE_AS_SILENT = "treat_vibrate_as_silent";
- public static final String DND_ON_SILENT_MODE = "dnd_on_silent_mode";
- public static final String MANUALLY_CHANGE_PRESENCE = "manually_change_presence";
- public static final String BLIND_TRUST_BEFORE_VERIFICATION = "btbv";
- public static final String AUTOMATIC_MESSAGE_DELETION = "automatic_message_deletion";
- public static final String BROADCAST_LAST_ACTIVITY = "last_activity";
- public static final String THEME = "theme";
- public static final String SHOW_DYNAMIC_TAGS = "show_dynamic_tags";
- public static final String OMEMO_SETTING = "omemo";
- public static final String PREVENT_SCREENSHOTS = "prevent_screenshots";
-
- public static final int REQUEST_CREATE_BACKUP = 0xbf8701;
-
- private SettingsFragment mSettingsFragment;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_settings);
- FragmentManager fm = getFragmentManager();
- mSettingsFragment = (SettingsFragment) fm.findFragmentById(R.id.settings_content);
- if (mSettingsFragment == null || !mSettingsFragment.getClass().equals(SettingsFragment.class)) {
- mSettingsFragment = new SettingsFragment();
- fm.beginTransaction().replace(R.id.settings_content, mSettingsFragment).commit();
- }
- mSettingsFragment.setActivityIntent(getIntent());
- this.mTheme = findTheme();
- setTheme(this.mTheme);
- getWindow().getDecorView().setBackgroundColor(StyledAttributes.getColor(this, R.attr.color_background_primary));
- setSupportActionBar(findViewById(R.id.toolbar));
- configureActionBar(getSupportActionBar());
- }
-
- @Override
- void onBackendConnected() {
-
- }
-
- @Override
- public void onStart() {
- super.onStart();
- PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this);
-
- changeOmemoSettingSummary();
-
- if (QuickConversationsService.isQuicksy()) {
- final PreferenceCategory connectionOptions = (PreferenceCategory) mSettingsFragment.findPreference("connection_options");
- final PreferenceCategory groupChats = (PreferenceCategory) mSettingsFragment.findPreference("group_chats");
- final Preference channelDiscoveryMethod = mSettingsFragment.findPreference("channel_discovery_method");
- PreferenceScreen expert = (PreferenceScreen) mSettingsFragment.findPreference("expert");
- if (connectionOptions != null) {
- expert.removePreference(connectionOptions);
- }
- if (groupChats != null && channelDiscoveryMethod != null) {
- groupChats.removePreference(channelDiscoveryMethod);
- }
- }
-
- PreferenceScreen mainPreferenceScreen = (PreferenceScreen) mSettingsFragment.findPreference("main_screen");
-
- PreferenceCategory attachmentsCategory = (PreferenceCategory) mSettingsFragment.findPreference("attachments");
- CheckBoxPreference locationPlugin = (CheckBoxPreference) mSettingsFragment.findPreference("use_share_location_plugin");
- if (attachmentsCategory != null && locationPlugin != null) {
- if (!GeoHelper.isLocationPluginInstalled(this)) {
- attachmentsCategory.removePreference(locationPlugin);
- }
- }
-
- //this feature is only available on Huawei Android 6.
- PreferenceScreen huaweiPreferenceScreen = (PreferenceScreen) mSettingsFragment.findPreference("huawei");
- if (huaweiPreferenceScreen != null) {
- Intent intent = huaweiPreferenceScreen.getIntent();
- //remove when Api version is above M (Version 6.0) or if the intent is not callable
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M || !isCallable(intent)) {
- PreferenceCategory generalCategory = (PreferenceCategory) mSettingsFragment.findPreference("general");
- generalCategory.removePreference(huaweiPreferenceScreen);
- if (generalCategory.getPreferenceCount() == 0) {
- if (mainPreferenceScreen != null) {
- mainPreferenceScreen.removePreference(generalCategory);
- }
- }
- }
- }
-
- ListPreference automaticMessageDeletionList = (ListPreference) mSettingsFragment.findPreference(AUTOMATIC_MESSAGE_DELETION);
- if (automaticMessageDeletionList != null) {
- final int[] choices = getResources().getIntArray(R.array.automatic_message_deletion_values);
- CharSequence[] entries = new CharSequence[choices.length];
- CharSequence[] entryValues = new CharSequence[choices.length];
- for (int i = 0; i < choices.length; ++i) {
- entryValues[i] = String.valueOf(choices[i]);
- if (choices[i] == 0) {
- entries[i] = getString(R.string.never);
- } else {
- entries[i] = TimeFrameUtils.resolve(this, 1000L * choices[i]);
- }
- }
- automaticMessageDeletionList.setEntries(entries);
- automaticMessageDeletionList.setEntryValues(entryValues);
- }
-
-
- boolean removeLocation = new Intent("eu.siacs.conversations.location.request").resolveActivity(getPackageManager()) == null;
- boolean removeVoice = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION).resolveActivity(getPackageManager()) == null;
-
- ListPreference quickAction = (ListPreference) mSettingsFragment.findPreference("quick_action");
- if (quickAction != null && (removeLocation || removeVoice)) {
- ArrayList<CharSequence> entries = new ArrayList<>(Arrays.asList(quickAction.getEntries()));
- ArrayList<CharSequence> entryValues = new ArrayList<>(Arrays.asList(quickAction.getEntryValues()));
- int index = entryValues.indexOf("location");
- if (index > 0 && removeLocation) {
- entries.remove(index);
- entryValues.remove(index);
- }
- index = entryValues.indexOf("voice");
- if (index > 0 && removeVoice) {
- entries.remove(index);
- entryValues.remove(index);
- }
- quickAction.setEntries(entries.toArray(new CharSequence[entries.size()]));
- quickAction.setEntryValues(entryValues.toArray(new CharSequence[entryValues.size()]));
- }
-
- final Preference removeCertsPreference = mSettingsFragment.findPreference("remove_trusted_certificates");
- if (removeCertsPreference != null) {
- removeCertsPreference.setOnPreferenceClickListener(preference -> {
- final MemorizingTrustManager mtm = xmppConnectionService.getMemorizingTrustManager();
- final ArrayList<String> aliases = Collections.list(mtm.getCertificates());
- if (aliases.size() == 0) {
- displayToast(getString(R.string.toast_no_trusted_certs));
- return true;
- }
- final ArrayList<Integer> selectedItems = new ArrayList<>();
- final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(SettingsActivity.this);
- dialogBuilder.setTitle(getResources().getString(R.string.dialog_manage_certs_title));
- dialogBuilder.setMultiChoiceItems(aliases.toArray(new CharSequence[aliases.size()]), null,
- (dialog, indexSelected, isChecked) -> {
- if (isChecked) {
- selectedItems.add(indexSelected);
- } else if (selectedItems.contains(indexSelected)) {
- selectedItems.remove(Integer.valueOf(indexSelected));
- }
- ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(selectedItems.size() > 0);
- });
-
- dialogBuilder.setPositiveButton(
- getResources().getString(R.string.dialog_manage_certs_positivebutton), (dialog, which) -> {
- int count = selectedItems.size();
- if (count > 0) {
- for (int i = 0; i < count; i++) {
- try {
- Integer item = Integer.valueOf(selectedItems.get(i).toString());
- String alias = aliases.get(item);
- mtm.deleteCertificate(alias);
- } catch (KeyStoreException e) {
- e.printStackTrace();
- displayToast("Error: " + e.getLocalizedMessage());
- }
- }
- if (xmppConnectionServiceBound) {
- reconnectAccounts();
- }
- displayToast(getResources().getQuantityString(R.plurals.toast_delete_certificates, count, count));
- }
- });
- dialogBuilder.setNegativeButton(getResources().getString(R.string.dialog_manage_certs_negativebutton), null);
- AlertDialog removeCertsDialog = dialogBuilder.create();
- removeCertsDialog.show();
- removeCertsDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
- return true;
- });
- }
-
- final Preference createBackupPreference = mSettingsFragment.findPreference("create_backup");
- if (createBackupPreference != null) {
- createBackupPreference.setSummary(getString(R.string.pref_create_backup_summary, FileBackend.getBackupDirectory(this).getAbsolutePath()));
- createBackupPreference.setOnPreferenceClickListener(preference -> {
- if (hasStoragePermission(REQUEST_CREATE_BACKUP)) {
- createBackup();
- }
- return true;
- });
- }
-
- if (Config.ONLY_INTERNAL_STORAGE) {
- final Preference cleanCachePreference = mSettingsFragment.findPreference("clean_cache");
- if (cleanCachePreference != null) {
- cleanCachePreference.setOnPreferenceClickListener(preference -> cleanCache());
- }
-
- final Preference cleanPrivateStoragePreference = mSettingsFragment.findPreference("clean_private_storage");
- if (cleanPrivateStoragePreference != null) {
- cleanPrivateStoragePreference.setOnPreferenceClickListener(preference -> cleanPrivateStorage());
- }
- }
-
- final Preference deleteOmemoPreference = mSettingsFragment.findPreference("delete_omemo_identities");
- if (deleteOmemoPreference != null) {
- deleteOmemoPreference.setOnPreferenceClickListener(preference -> deleteOmemoIdentities());
- }
- }
-
- private void changeOmemoSettingSummary() {
- ListPreference omemoPreference = (ListPreference) mSettingsFragment.findPreference(OMEMO_SETTING);
- if (omemoPreference != null) {
- String value = omemoPreference.getValue();
- switch (value) {
- case "always":
- omemoPreference.setSummary(R.string.pref_omemo_setting_summary_always);
- break;
- case "default_on":
- omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_on);
- break;
- case "default_off":
- omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_off);
- break;
- }
- } else {
- Log.d(Config.LOGTAG,"unable to find preference named "+OMEMO_SETTING);
- }
- }
-
- private boolean isCallable(final Intent i) {
- return i != null && getPackageManager().queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY).size() > 0;
- }
-
-
- private boolean cleanCache() {
- Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
- intent.setData(Uri.parse("package:" + getPackageName()));
- startActivity(intent);
- return true;
- }
-
- private boolean cleanPrivateStorage() {
- for(String type : Arrays.asList("Images", "Videos", "Files", "Recordings")) {
- cleanPrivateFiles(type);
- }
- return true;
- }
-
- private void cleanPrivateFiles(final String type) {
- try {
- File dir = new File(getFilesDir().getAbsolutePath(), "/" + type + "/");
- File[] array = dir.listFiles();
- if (array != null) {
- for (int b = 0; b < array.length; b++) {
- String name = array[b].getName().toLowerCase();
- if (name.equals(".nomedia")) {
- continue;
- }
- if (array[b].isFile()) {
- array[b].delete();
- }
- }
- }
- } catch (Throwable e) {
- Log.e("CleanCache", e.toString());
- }
- }
-
- private boolean deleteOmemoIdentities() {
- AlertDialog.Builder builder = new AlertDialog.Builder(this);
- builder.setTitle(R.string.pref_delete_omemo_identities);
- final List<CharSequence> accounts = new ArrayList<>();
- for (Account account : xmppConnectionService.getAccounts()) {
- if (account.isEnabled()) {
- accounts.add(account.getJid().asBareJid().toString());
- }
- }
- final boolean[] checkedItems = new boolean[accounts.size()];
- builder.setMultiChoiceItems(accounts.toArray(new CharSequence[accounts.size()]), checkedItems, (dialog, which, isChecked) -> {
- checkedItems[which] = isChecked;
- final AlertDialog alertDialog = (AlertDialog) dialog;
- for (boolean item : checkedItems) {
- if (item) {
- alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(true);
- return;
- }
- }
- alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
- });
- builder.setNegativeButton(R.string.cancel, null);
- builder.setPositiveButton(R.string.delete_selected_keys, (dialog, which) -> {
- for (int i = 0; i < checkedItems.length; ++i) {
- if (checkedItems[i]) {
- try {
- Jid jid = Jid.of(accounts.get(i).toString());
- Account account = xmppConnectionService.findAccountByJid(jid);
- if (account != null) {
- account.getAxolotlService().regenerateKeys(true);
- }
- } catch (IllegalArgumentException e) {
- //
- }
-
- }
- }
- });
- AlertDialog dialog = builder.create();
- dialog.show();
- dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
- return true;
- }
-
- @Override
- public void onStop() {
- super.onStop();
- PreferenceManager.getDefaultSharedPreferences(this)
- .unregisterOnSharedPreferenceChangeListener(this);
- }
-
- @Override
- public void onSharedPreferenceChanged(SharedPreferences preferences, String name) {
- final List<String> resendPresence = Arrays.asList(
- "confirm_messages",
- DND_ON_SILENT_MODE,
- AWAY_WHEN_SCREEN_IS_OFF,
- "allow_message_correction",
- TREAT_VIBRATE_AS_SILENT,
- MANUALLY_CHANGE_PRESENCE,
- BROADCAST_LAST_ACTIVITY);
- if (name.equals(OMEMO_SETTING)) {
- OmemoSetting.load(this, preferences);
- changeOmemoSettingSummary();
- } else if (name.equals(KEEP_FOREGROUND_SERVICE)) {
- xmppConnectionService.toggleForegroundService();
- } else if (resendPresence.contains(name)) {
- if (xmppConnectionServiceBound) {
- if (name.equals(AWAY_WHEN_SCREEN_IS_OFF) || name.equals(MANUALLY_CHANGE_PRESENCE)) {
- xmppConnectionService.toggleScreenEventReceiver();
- }
- xmppConnectionService.refreshAllPresences();
- }
- } else if (name.equals("dont_trust_system_cas")) {
- xmppConnectionService.updateMemorizingTrustmanager();
- reconnectAccounts();
- } else if (name.equals("use_tor")) {
- reconnectAccounts();
- xmppConnectionService.reinitializeMuclumbusService();
- } else if (name.equals(AUTOMATIC_MESSAGE_DELETION)) {
- xmppConnectionService.expireOldMessages(true);
- } else if (name.equals(THEME)) {
- final int theme = findTheme();
- if (this.mTheme != theme) {
- xmppConnectionService.setTheme(theme);
- recreate();
- }
- } else if(name.equals(PREVENT_SCREENSHOTS)){
- SettingsUtils.applyScreenshotPreventionSetting(this);
- }
- }
-
- @Override
- public void onResume(){
- super.onResume();
- SettingsUtils.applyScreenshotPreventionSetting(this);
- }
-
- @Override
- public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
- if (grantResults.length > 0)
- if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- if (requestCode == REQUEST_CREATE_BACKUP) {
- createBackup();
- }
- } else {
- Toast.makeText(this, getString(R.string.no_storage_permission, getString(R.string.app_name)), Toast.LENGTH_SHORT).show();
- }
- }
-
- private void createBackup() {
- ContextCompat.startForegroundService(this, new Intent(this, ExportBackupService.class));
- final AlertDialog.Builder builder = new AlertDialog.Builder(this);
- builder.setMessage(R.string.backup_started_message);
- builder.setPositiveButton(R.string.ok, null);
- builder.create().show();
- }
-
- private void displayToast(final String msg) {
- runOnUiThread(() -> Toast.makeText(SettingsActivity.this, msg, Toast.LENGTH_LONG).show());
- }
-
- private void reconnectAccounts() {
- for (Account account : xmppConnectionService.getAccounts()) {
- if (account.isEnabled()) {
- xmppConnectionService.reconnectAccountInBackground(account);
- }
- }
- }
-
- public void refreshUiReal() {
- //nothing to do. This Activity doesn't implement any listeners
- }
-
+public class SettingsActivity extends XmppActivity implements OnSharedPreferenceChangeListener {
+
+ public static final String KEEP_FOREGROUND_SERVICE = "enable_foreground_service";
+ public static final String AWAY_WHEN_SCREEN_IS_OFF = "away_when_screen_off";
+ public static final String TREAT_VIBRATE_AS_SILENT = "treat_vibrate_as_silent";
+ public static final String DND_ON_SILENT_MODE = "dnd_on_silent_mode";
+ public static final String MANUALLY_CHANGE_PRESENCE = "manually_change_presence";
+ public static final String BLIND_TRUST_BEFORE_VERIFICATION = "btbv";
+ public static final String AUTOMATIC_MESSAGE_DELETION = "automatic_message_deletion";
+ public static final String BROADCAST_LAST_ACTIVITY = "last_activity";
+ public static final String THEME = "theme";
+ public static final String SHOW_DYNAMIC_TAGS = "show_dynamic_tags";
+ public static final String OMEMO_SETTING = "omemo";
+ public static final String PREVENT_SCREENSHOTS = "prevent_screenshots";
+
+ public static final int REQUEST_CREATE_BACKUP = 0xbf8701;
+
+ private SettingsFragment mSettingsFragment;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_settings);
+ FragmentManager fm = getFragmentManager();
+ mSettingsFragment = (SettingsFragment) fm.findFragmentById(R.id.settings_content);
+ if (mSettingsFragment == null
+ || !mSettingsFragment.getClass().equals(SettingsFragment.class)) {
+ mSettingsFragment = new SettingsFragment();
+ fm.beginTransaction().replace(R.id.settings_content, mSettingsFragment).commit();
+ }
+ mSettingsFragment.setActivityIntent(getIntent());
+ this.mTheme = findTheme();
+ setTheme(this.mTheme);
+ getWindow()
+ .getDecorView()
+ .setBackgroundColor(
+ StyledAttributes.getColor(this, R.attr.color_background_primary));
+ setSupportActionBar(findViewById(R.id.toolbar));
+ configureActionBar(getSupportActionBar());
+ }
+
+ @Override
+ void onBackendConnected() {}
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ PreferenceManager.getDefaultSharedPreferences(this)
+ .registerOnSharedPreferenceChangeListener(this);
+
+ changeOmemoSettingSummary();
+
+ if (QuickConversationsService.isQuicksy()
+ || Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
+ final PreferenceCategory groupChats =
+ (PreferenceCategory) mSettingsFragment.findPreference("group_chats");
+ final Preference channelDiscoveryMethod =
+ mSettingsFragment.findPreference("channel_discovery_method");
+ if (groupChats != null && channelDiscoveryMethod != null) {
+ groupChats.removePreference(channelDiscoveryMethod);
+ }
+ }
+
+ if (QuickConversationsService.isQuicksy()) {
+ final PreferenceCategory connectionOptions =
+ (PreferenceCategory) mSettingsFragment.findPreference("connection_options");
+ PreferenceScreen expert = (PreferenceScreen) mSettingsFragment.findPreference("expert");
+ if (connectionOptions != null) {
+ expert.removePreference(connectionOptions);
+ }
+ }
+
+ PreferenceScreen mainPreferenceScreen =
+ (PreferenceScreen) mSettingsFragment.findPreference("main_screen");
+
+ PreferenceCategory attachmentsCategory =
+ (PreferenceCategory) mSettingsFragment.findPreference("attachments");
+ CheckBoxPreference locationPlugin =
+ (CheckBoxPreference) mSettingsFragment.findPreference("use_share_location_plugin");
+ if (attachmentsCategory != null && locationPlugin != null) {
+ if (!GeoHelper.isLocationPluginInstalled(this)) {
+ attachmentsCategory.removePreference(locationPlugin);
+ }
+ }
+
+ // this feature is only available on Huawei Android 6.
+ PreferenceScreen huaweiPreferenceScreen =
+ (PreferenceScreen) mSettingsFragment.findPreference("huawei");
+ if (huaweiPreferenceScreen != null) {
+ Intent intent = huaweiPreferenceScreen.getIntent();
+ // remove when Api version is above M (Version 6.0) or if the intent is not callable
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M || !isCallable(intent)) {
+ PreferenceCategory generalCategory =
+ (PreferenceCategory) mSettingsFragment.findPreference("general");
+ generalCategory.removePreference(huaweiPreferenceScreen);
+ if (generalCategory.getPreferenceCount() == 0) {
+ if (mainPreferenceScreen != null) {
+ mainPreferenceScreen.removePreference(generalCategory);
+ }
+ }
+ }
+ }
+
+ ListPreference automaticMessageDeletionList =
+ (ListPreference) mSettingsFragment.findPreference(AUTOMATIC_MESSAGE_DELETION);
+ if (automaticMessageDeletionList != null) {
+ final int[] choices =
+ getResources().getIntArray(R.array.automatic_message_deletion_values);
+ CharSequence[] entries = new CharSequence[choices.length];
+ CharSequence[] entryValues = new CharSequence[choices.length];
+ for (int i = 0; i < choices.length; ++i) {
+ entryValues[i] = String.valueOf(choices[i]);
+ if (choices[i] == 0) {
+ entries[i] = getString(R.string.never);
+ } else {
+ entries[i] = TimeFrameUtils.resolve(this, 1000L * choices[i]);
+ }
+ }
+ automaticMessageDeletionList.setEntries(entries);
+ automaticMessageDeletionList.setEntryValues(entryValues);
+ }
+
+ boolean removeLocation =
+ new Intent("eu.siacs.conversations.location.request")
+ .resolveActivity(getPackageManager())
+ == null;
+ boolean removeVoice =
+ new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)
+ .resolveActivity(getPackageManager())
+ == null;
+
+ ListPreference quickAction =
+ (ListPreference) mSettingsFragment.findPreference("quick_action");
+ if (quickAction != null && (removeLocation || removeVoice)) {
+ ArrayList<CharSequence> entries =
+ new ArrayList<>(Arrays.asList(quickAction.getEntries()));
+ ArrayList<CharSequence> entryValues =
+ new ArrayList<>(Arrays.asList(quickAction.getEntryValues()));
+ int index = entryValues.indexOf("location");
+ if (index > 0 && removeLocation) {
+ entries.remove(index);
+ entryValues.remove(index);
+ }
+ index = entryValues.indexOf("voice");
+ if (index > 0 && removeVoice) {
+ entries.remove(index);
+ entryValues.remove(index);
+ }
+ quickAction.setEntries(entries.toArray(new CharSequence[entries.size()]));
+ quickAction.setEntryValues(entryValues.toArray(new CharSequence[entryValues.size()]));
+ }
+
+ final Preference removeCertsPreference =
+ mSettingsFragment.findPreference("remove_trusted_certificates");
+ if (removeCertsPreference != null) {
+ removeCertsPreference.setOnPreferenceClickListener(
+ preference -> {
+ final MemorizingTrustManager mtm =
+ xmppConnectionService.getMemorizingTrustManager();
+ final ArrayList<String> aliases = Collections.list(mtm.getCertificates());
+ if (aliases.size() == 0) {
+ displayToast(getString(R.string.toast_no_trusted_certs));
+ return true;
+ }
+ final ArrayList<Integer> selectedItems = new ArrayList<>();
+ final AlertDialog.Builder dialogBuilder =
+ new AlertDialog.Builder(SettingsActivity.this);
+ dialogBuilder.setTitle(
+ getResources().getString(R.string.dialog_manage_certs_title));
+ dialogBuilder.setMultiChoiceItems(
+ aliases.toArray(new CharSequence[aliases.size()]),
+ null,
+ (dialog, indexSelected, isChecked) -> {
+ if (isChecked) {
+ selectedItems.add(indexSelected);
+ } else if (selectedItems.contains(indexSelected)) {
+ selectedItems.remove(Integer.valueOf(indexSelected));
+ }
+ ((AlertDialog) dialog)
+ .getButton(DialogInterface.BUTTON_POSITIVE)
+ .setEnabled(selectedItems.size() > 0);
+ });
+
+ dialogBuilder.setPositiveButton(
+ getResources()
+ .getString(R.string.dialog_manage_certs_positivebutton),
+ (dialog, which) -> {
+ int count = selectedItems.size();
+ if (count > 0) {
+ for (int i = 0; i < count; i++) {
+ try {
+ Integer item =
+ Integer.valueOf(
+ selectedItems.get(i).toString());
+ String alias = aliases.get(item);
+ mtm.deleteCertificate(alias);
+ } catch (KeyStoreException e) {
+ e.printStackTrace();
+ displayToast("Error: " + e.getLocalizedMessage());
+ }
+ }
+ if (xmppConnectionServiceBound) {
+ reconnectAccounts();
+ }
+ displayToast(
+ getResources()
+ .getQuantityString(
+ R.plurals.toast_delete_certificates,
+ count,
+ count));
+ }
+ });
+ dialogBuilder.setNegativeButton(
+ getResources()
+ .getString(R.string.dialog_manage_certs_negativebutton),
+ null);
+ AlertDialog removeCertsDialog = dialogBuilder.create();
+ removeCertsDialog.show();
+ removeCertsDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
+ return true;
+ });
+ }
+
+ final Preference createBackupPreference = mSettingsFragment.findPreference("create_backup");
+ if (createBackupPreference != null) {
+ createBackupPreference.setSummary(
+ getString(
+ R.string.pref_create_backup_summary,
+ FileBackend.getBackupDirectory(this).getAbsolutePath()));
+ createBackupPreference.setOnPreferenceClickListener(
+ preference -> {
+ if (hasStoragePermission(REQUEST_CREATE_BACKUP)) {
+ createBackup();
+ }
+ return true;
+ });
+ }
+
+ if (Config.ONLY_INTERNAL_STORAGE) {
+ final Preference cleanCachePreference = mSettingsFragment.findPreference("clean_cache");
+ if (cleanCachePreference != null) {
+ cleanCachePreference.setOnPreferenceClickListener(preference -> cleanCache());
+ }
+
+ final Preference cleanPrivateStoragePreference =
+ mSettingsFragment.findPreference("clean_private_storage");
+ if (cleanPrivateStoragePreference != null) {
+ cleanPrivateStoragePreference.setOnPreferenceClickListener(
+ preference -> cleanPrivateStorage());
+ }
+ }
+
+ final Preference deleteOmemoPreference =
+ mSettingsFragment.findPreference("delete_omemo_identities");
+ if (deleteOmemoPreference != null) {
+ deleteOmemoPreference.setOnPreferenceClickListener(
+ preference -> deleteOmemoIdentities());
+ }
+ if (Config.omemoOnly()) {
+ final PreferenceCategory privacyCategory =
+ (PreferenceCategory) mSettingsFragment.findPreference("privacy");
+ final Preference omemoPreference =mSettingsFragment.findPreference(OMEMO_SETTING);
+ if (omemoPreference != null) {
+ privacyCategory.removePreference(omemoPreference);
+ }
+ }
+ }
+
+ private void changeOmemoSettingSummary() {
+ final ListPreference omemoPreference =
+ (ListPreference) mSettingsFragment.findPreference(OMEMO_SETTING);
+ if (omemoPreference == null) {
+ return;
+ }
+ final String value = omemoPreference.getValue();
+ switch (value) {
+ case "always":
+ omemoPreference.setSummary(R.string.pref_omemo_setting_summary_always);
+ break;
+ case "default_on":
+ omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_on);
+ break;
+ case "default_off":
+ omemoPreference.setSummary(R.string.pref_omemo_setting_summary_default_off);
+ break;
+ }
+ }
+
+ private boolean isCallable(final Intent i) {
+ return i != null
+ && getPackageManager()
+ .queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY)
+ .size()
+ > 0;
+ }
+
+ private boolean cleanCache() {
+ Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ startActivity(intent);
+ return true;
+ }
+
+ private boolean cleanPrivateStorage() {
+ for (String type : Arrays.asList("Images", "Videos", "Files", "Recordings")) {
+ cleanPrivateFiles(type);
+ }
+ return true;
+ }
+
+ private void cleanPrivateFiles(final String type) {
+ try {
+ File dir = new File(getFilesDir().getAbsolutePath(), "/" + type + "/");
+ File[] array = dir.listFiles();
+ if (array != null) {
+ for (int b = 0; b < array.length; b++) {
+ String name = array[b].getName().toLowerCase();
+ if (name.equals(".nomedia")) {
+ continue;
+ }
+ if (array[b].isFile()) {
+ array[b].delete();
+ }
+ }
+ }
+ } catch (Throwable e) {
+ Log.e("CleanCache", e.toString());
+ }
+ }
+
+ private boolean deleteOmemoIdentities() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.pref_delete_omemo_identities);
+ final List<CharSequence> accounts = new ArrayList<>();
+ for (Account account : xmppConnectionService.getAccounts()) {
+ if (account.isEnabled()) {
+ accounts.add(account.getJid().asBareJid().toString());
+ }
+ }
+ final boolean[] checkedItems = new boolean[accounts.size()];
+ builder.setMultiChoiceItems(
+ accounts.toArray(new CharSequence[accounts.size()]),
+ checkedItems,
+ (dialog, which, isChecked) -> {
+ checkedItems[which] = isChecked;
+ final AlertDialog alertDialog = (AlertDialog) dialog;
+ for (boolean item : checkedItems) {
+ if (item) {
+ alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(true);
+ return;
+ }
+ }
+ alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
+ });
+ builder.setNegativeButton(R.string.cancel, null);
+ builder.setPositiveButton(
+ R.string.delete_selected_keys,
+ (dialog, which) -> {
+ for (int i = 0; i < checkedItems.length; ++i) {
+ if (checkedItems[i]) {
+ try {
+ Jid jid = Jid.of(accounts.get(i).toString());
+ Account account = xmppConnectionService.findAccountByJid(jid);
+ if (account != null) {
+ account.getAxolotlService().regenerateKeys(true);
+ }
+ } catch (IllegalArgumentException e) {
+ //
+ }
+ }
+ }
+ });
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
+ return true;
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ PreferenceManager.getDefaultSharedPreferences(this)
+ .unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences preferences, String name) {
+ final List<String> resendPresence =
+ Arrays.asList(
+ "confirm_messages",
+ DND_ON_SILENT_MODE,
+ AWAY_WHEN_SCREEN_IS_OFF,
+ "allow_message_correction",
+ TREAT_VIBRATE_AS_SILENT,
+ MANUALLY_CHANGE_PRESENCE,
+ BROADCAST_LAST_ACTIVITY);
+ if (name.equals(OMEMO_SETTING)) {
+ OmemoSetting.load(this, preferences);
+ changeOmemoSettingSummary();
+ } else if (name.equals(KEEP_FOREGROUND_SERVICE)) {
+ xmppConnectionService.toggleForegroundService();
+ } else if (resendPresence.contains(name)) {
+ if (xmppConnectionServiceBound) {
+ if (name.equals(AWAY_WHEN_SCREEN_IS_OFF) || name.equals(MANUALLY_CHANGE_PRESENCE)) {
+ xmppConnectionService.toggleScreenEventReceiver();
+ }
+ xmppConnectionService.refreshAllPresences();
+ }
+ } else if (name.equals("dont_trust_system_cas")) {
+ xmppConnectionService.updateMemorizingTrustmanager();
+ reconnectAccounts();
+ } else if (name.equals("use_tor")) {
+ if (preferences.getBoolean(name, false)) {
+ displayToast(getString(R.string.audio_video_disabled_tor));
+ }
+ reconnectAccounts();
+ xmppConnectionService.reinitializeMuclumbusService();
+ } else if (name.equals(AUTOMATIC_MESSAGE_DELETION)) {
+ xmppConnectionService.expireOldMessages(true);
+ } else if (name.equals(THEME)) {
+ final int theme = findTheme();
+ if (this.mTheme != theme) {
+ xmppConnectionService.setTheme(theme);
+ recreate();
+ }
+ } else if (name.equals(PREVENT_SCREENSHOTS)) {
+ SettingsUtils.applyScreenshotPreventionSetting(this);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ SettingsUtils.applyScreenshotPreventionSetting(this);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (grantResults.length > 0)
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (requestCode == REQUEST_CREATE_BACKUP) {
+ createBackup();
+ }
+ } else {
+ Toast.makeText(
+ this,
+ getString(
+ R.string.no_storage_permission,
+ getString(R.string.app_name)),
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+ }
+
+ private void createBackup() {
+ ContextCompat.startForegroundService(this, new Intent(this, ExportBackupService.class));
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setMessage(R.string.backup_started_message);
+ builder.setPositiveButton(R.string.ok, null);
+ builder.create().show();
+ }
+
+ private void displayToast(final String msg) {
+ runOnUiThread(() -> Toast.makeText(SettingsActivity.this, msg, Toast.LENGTH_LONG).show());
+ }
+
+ private void reconnectAccounts() {
+ for (Account account : xmppConnectionService.getAccounts()) {
+ if (account.isEnabled()) {
+ xmppConnectionService.reconnectAccountInBackground(account);
+ }
+ }
+ }
+
+ public void refreshUiReal() {
+ // nothing to do. This Activity doesn't implement any listeners
+ }
}
@@ -3,8 +3,8 @@ package eu.siacs.conversations.ui;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
-import android.content.ComponentName;
import android.content.Intent;
+import android.content.pm.ActivityInfo;
import android.location.Location;
import android.location.LocationListener;
import android.net.Uri;
@@ -17,6 +17,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil;
+import org.jetbrains.annotations.NotNull;
import org.osmdroid.util.GeoPoint;
import java.util.HashMap;
@@ -32,198 +33,214 @@ import eu.siacs.conversations.ui.widget.Marker;
import eu.siacs.conversations.ui.widget.MyLocation;
import eu.siacs.conversations.utils.LocationProvider;
-
public class ShowLocationActivity extends LocationActivity implements LocationListener {
- private GeoPoint loc = LocationProvider.FALLBACK;
- private ActivityShowLocationBinding binding;
-
-
- private Uri createGeoUri() {
- return Uri.parse("geo:" + this.loc.getLatitude() + "," + this.loc.getLongitude());
- }
-
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- this.binding = DataBindingUtil.setContentView(this,R.layout.activity_show_location);
- setSupportActionBar(binding.toolbar);
-
- configureActionBar(getSupportActionBar());
- setupMapView(this.binding.map, this.loc);
-
- this.binding.fab.setOnClickListener(view -> startNavigation());
-
- final Intent intent = getIntent();
- if (intent != null) {
- final String action = intent.getAction();
- if (action == null) {
- return;
- }
- switch (action) {
- case "eu.siacs.conversations.location.show":
- if (intent.hasExtra("longitude") && intent.hasExtra("latitude")) {
- final double longitude = intent.getDoubleExtra("longitude", 0);
- final double latitude = intent.getDoubleExtra("latitude", 0);
- this.loc = new GeoPoint(latitude, longitude);
- }
- break;
- case Intent.ACTION_VIEW:
- final Uri geoUri = intent.getData();
-
- // Attempt to set zoom level if the geo URI specifies it
- if (geoUri != null) {
- final HashMap<String, String> query = UriHelper.parseQueryString(geoUri.getQuery());
-
- // Check for zoom level.
- final String z = query.get("z");
- if (z != null) {
- try {
- mapController.setZoom(Double.valueOf(z));
- } catch (final Exception ignored) {
- }
- }
-
- // Check for the actual geo query.
- boolean posInQuery = false;
- final String q = query.get("q");
- if (q != null) {
- final Pattern latlng = Pattern.compile("/^([-+]?[0-9]+(\\.[0-9]+)?),([-+]?[0-9]+(\\.[0-9]+)?)(\\(.*\\))?/");
- final Matcher m = latlng.matcher(q);
- if (m.matches()) {
- try {
- this.loc = new GeoPoint(Double.valueOf(m.group(1)), Double.valueOf(m.group(3)));
- posInQuery = true;
- } catch (final Exception ignored) {
- }
- }
- }
-
- final String schemeSpecificPart = geoUri.getSchemeSpecificPart();
- if (schemeSpecificPart != null && !schemeSpecificPart.isEmpty()) {
- try {
- final GeoPoint latlong = LocationHelper.parseLatLong(schemeSpecificPart);
- if (latlong != null && !posInQuery) {
- this.loc = latlong;
- }
- } catch (final NumberFormatException ignored) {
- }
- }
- }
-
- break;
- }
- updateLocationMarkers();
- }
- }
-
- @Override
- protected void gotoLoc(final boolean setZoomLevel) {
- if (this.loc != null && mapController != null) {
- if (setZoomLevel) {
- mapController.setZoom(Config.Map.FINAL_ZOOM_LEVEL);
- }
- mapController.animateTo(new GeoPoint(this.loc));
- }
- }
-
- @Override
- public void onRequestPermissionsResult(final int requestCode,
- @NonNull final String[] permissions,
- @NonNull final int[] grantResults) {
- super.onRequestPermissionsResult(requestCode, permissions, grantResults);
- updateUi();
- }
-
- @Override
- protected void setMyLoc(final Location location) {
- this.myLoc = location;
- }
-
- @Override
- public boolean onCreateOptionsMenu(final Menu menu) {
- // Inflate the menu; this adds items to the action bar if it is present.
- getMenuInflater().inflate(R.menu.menu_show_location, menu);
- updateUi();
- return true;
- }
-
- @Override
- protected void updateLocationMarkers() {
- super.updateLocationMarkers();
- if (this.myLoc != null) {
- this.binding.map.getOverlays().add(new MyLocation(this, null, this.myLoc));
- }
- this.binding.map.getOverlays().add(new Marker(this.marker_icon, this.loc));
- }
-
- @Override
- protected void onPause() {
- super.onPause();
- }
-
- @Override
- public boolean onOptionsItemSelected(final MenuItem item) {
- switch (item.getItemId()) {
- case R.id.action_copy_location:
- final ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
- if (clipboard != null) {
- final ClipData clip = ClipData.newPlainText("location", createGeoUri().toString());
- clipboard.setPrimaryClip(clip);
- Toast.makeText(this,R.string.url_copied_to_clipboard,Toast.LENGTH_SHORT).show();
- }
- return true;
- case R.id.action_share_location:
- final Intent shareIntent = new Intent();
- shareIntent.setAction(Intent.ACTION_SEND);
- shareIntent.putExtra(Intent.EXTRA_TEXT, createGeoUri().toString());
- shareIntent.setType("text/plain");
- try {
- startActivity(Intent.createChooser(shareIntent, getText(R.string.share_with)));
- } catch (final ActivityNotFoundException e) {
- //This should happen only on faulty androids because normally chooser is always available
- Toast.makeText(this, R.string.no_application_found_to_open_file, Toast.LENGTH_SHORT).show();
- }
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- private void startNavigation() {
- startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(
- "google.navigation:q=" +
- this.loc.getLatitude() + "," + this.loc.getLongitude()
- )));
- }
-
- @Override
- protected void updateUi() {
- final Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=0,0"));
- final ComponentName component = i.resolveActivity(getPackageManager());
- this.binding.fab.setVisibility(component == null ? View.GONE : View.VISIBLE);
- }
-
- @Override
- public void onLocationChanged(final Location location) {
- if (LocationHelper.isBetterLocation(location, this.myLoc)) {
- this.myLoc = location;
- updateLocationMarkers();
- }
- }
-
- @Override
- public void onStatusChanged(final String provider, final int status, final Bundle extras) {
-
- }
-
- @Override
- public void onProviderEnabled(final String provider) {
-
- }
-
- @Override
- public void onProviderDisabled(final String provider) {
-
- }
+ private GeoPoint loc = LocationProvider.FALLBACK;
+ private ActivityShowLocationBinding binding;
+
+ private Uri createGeoUri() {
+ return Uri.parse("geo:" + this.loc.getLatitude() + "," + this.loc.getLongitude());
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ this.binding = DataBindingUtil.setContentView(this, R.layout.activity_show_location);
+ setSupportActionBar(binding.toolbar);
+
+ configureActionBar(getSupportActionBar());
+ setupMapView(this.binding.map, this.loc);
+
+ this.binding.fab.setOnClickListener(view -> startNavigation());
+
+ final Intent intent = getIntent();
+ if (intent != null) {
+ final String action = intent.getAction();
+ if (action == null) {
+ return;
+ }
+ switch (action) {
+ case "eu.siacs.conversations.location.show":
+ if (intent.hasExtra("longitude") && intent.hasExtra("latitude")) {
+ final double longitude = intent.getDoubleExtra("longitude", 0);
+ final double latitude = intent.getDoubleExtra("latitude", 0);
+ this.loc = new GeoPoint(latitude, longitude);
+ }
+ break;
+ case Intent.ACTION_VIEW:
+ final Uri geoUri = intent.getData();
+
+ // Attempt to set zoom level if the geo URI specifies it
+ if (geoUri != null) {
+ final HashMap<String, String> query =
+ UriHelper.parseQueryString(geoUri.getQuery());
+
+ // Check for zoom level.
+ final String z = query.get("z");
+ if (z != null) {
+ try {
+ mapController.setZoom(Double.valueOf(z));
+ } catch (final Exception ignored) {
+ }
+ }
+
+ // Check for the actual geo query.
+ boolean posInQuery = false;
+ final String q = query.get("q");
+ if (q != null) {
+ final Pattern latlng =
+ Pattern.compile(
+ "/^([-+]?[0-9]+(\\.[0-9]+)?),([-+]?[0-9]+(\\.[0-9]+)?)(\\(.*\\))?/");
+ final Matcher m = latlng.matcher(q);
+ if (m.matches()) {
+ try {
+ this.loc =
+ new GeoPoint(
+ Double.valueOf(m.group(1)),
+ Double.valueOf(m.group(3)));
+ posInQuery = true;
+ } catch (final Exception ignored) {
+ }
+ }
+ }
+
+ final String schemeSpecificPart = geoUri.getSchemeSpecificPart();
+ if (schemeSpecificPart != null && !schemeSpecificPart.isEmpty()) {
+ try {
+ final GeoPoint latlong =
+ LocationHelper.parseLatLong(schemeSpecificPart);
+ if (latlong != null && !posInQuery) {
+ this.loc = latlong;
+ }
+ } catch (final NumberFormatException ignored) {
+ }
+ }
+ }
+
+ break;
+ }
+ updateLocationMarkers();
+ }
+ }
+
+ @Override
+ protected void gotoLoc(final boolean setZoomLevel) {
+ if (this.loc != null && mapController != null) {
+ if (setZoomLevel) {
+ mapController.setZoom(Config.Map.FINAL_ZOOM_LEVEL);
+ }
+ mapController.animateTo(new GeoPoint(this.loc));
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ final int requestCode,
+ @NonNull final String[] permissions,
+ @NonNull final int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ updateUi();
+ }
+
+ @Override
+ protected void setMyLoc(final Location location) {
+ this.myLoc = location;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(@NotNull final Menu menu) {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ getMenuInflater().inflate(R.menu.menu_show_location, menu);
+ updateUi();
+ return true;
+ }
+
+ @Override
+ protected void updateLocationMarkers() {
+ super.updateLocationMarkers();
+ if (this.myLoc != null) {
+ this.binding.map.getOverlays().add(new MyLocation(this, null, this.myLoc));
+ }
+ this.binding.map.getOverlays().add(new Marker(this.marker_icon, this.loc));
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_copy_location:
+ final ClipboardManager clipboard =
+ (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
+ if (clipboard != null) {
+ final ClipData clip =
+ ClipData.newPlainText("location", createGeoUri().toString());
+ clipboard.setPrimaryClip(clip);
+ Toast.makeText(this, R.string.url_copied_to_clipboard, Toast.LENGTH_SHORT)
+ .show();
+ }
+ return true;
+ case R.id.action_share_location:
+ final Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ shareIntent.putExtra(Intent.EXTRA_TEXT, createGeoUri().toString());
+ shareIntent.setType("text/plain");
+ try {
+ startActivity(Intent.createChooser(shareIntent, getText(R.string.share_with)));
+ } catch (final ActivityNotFoundException e) {
+ // This should happen only on faulty androids because normally chooser is always
+ // available
+ Toast.makeText(
+ this,
+ R.string.no_application_found_to_open_file,
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void startNavigation() {
+ final Intent intent = getStartNavigationIntent();
+ startActivity(intent);
+ }
+
+ private Intent getStartNavigationIntent() {
+ return new Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse(
+ "google.navigation:q="
+ + this.loc.getLatitude()
+ + ","
+ + this.loc.getLongitude()));
+ }
+
+ @Override
+ protected void updateUi() {
+ final Intent intent = getStartNavigationIntent();
+ final ActivityInfo activityInfo = intent.resolveActivityInfo(getPackageManager(), 0);
+ this.binding.fab.setVisibility(activityInfo == null ? View.GONE : View.VISIBLE);
+ }
+
+ @Override
+ public void onLocationChanged(@NotNull final Location location) {
+ if (LocationHelper.isBetterLocation(location, this.myLoc)) {
+ this.myLoc = location;
+ updateLocationMarkers();
+ }
+ }
+
+ @Override
+ public void onStatusChanged(final String provider, final int status, final Bundle extras) {}
+
+ @Override
+ public void onProviderEnabled(@NotNull final String provider) {}
+
+ @Override
+ public void onProviderDisabled(@NotNull final String provider) {}
}
@@ -1049,9 +1049,9 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
protected void filterConferences(String needle) {
this.conferences.clear();
- for (Account account : xmppConnectionService.getAccounts()) {
+ for (final Account account : xmppConnectionService.getAccounts()) {
if (account.getStatus() != Account.State.DISABLED) {
- for (Bookmark bookmark : account.getBookmarks()) {
+ for (final Bookmark bookmark : account.getBookmarks()) {
if (bookmark.match(this, needle)) {
this.conferences.add(bookmark);
}
@@ -1123,7 +1123,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
if (account == null) {
return;
}
- final String input = jid.getText().toString();
+ final String input = jid.getText().toString().trim();
Jid conferenceJid;
try {
conferenceJid = Jid.ofEscaped(input);
@@ -83,6 +83,7 @@ import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
import eu.siacs.conversations.ui.util.PresenceSelector;
import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.Compatibility;
import eu.siacs.conversations.utils.ExceptionHelper;
import eu.siacs.conversations.ui.util.SettingsUtils;
import eu.siacs.conversations.utils.ThemeHelper;
@@ -451,22 +452,12 @@ public abstract class XmppActivity extends ActionBarActivity {
final ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
return cm != null
&& cm.isActiveNetworkMetered()
- && getRestrictBackgroundStatus(cm) == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
+ && Compatibility.getRestrictBackgroundStatus(cm) == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED;
} else {
return false;
}
}
- @RequiresApi(api = Build.VERSION_CODES.N)
- private static int getRestrictBackgroundStatus(@NonNull final ConnectivityManager connectivityManager) {
- try {
- return connectivityManager.getRestrictBackgroundStatus();
- } catch (final Exception e) {
- Log.d(Config.LOGTAG,"platform bug detected. Unable to get restrict background status",e);
- return -1;
- }
- }
-
private boolean usingEnterKey() {
return getBooleanPreference("display_enter_key", R.bool.display_enter_key);
}
@@ -109,19 +109,19 @@ public class MessageAdapter extends ArrayAdapter<Message> {
private OnContactPictureClicked mOnContactPictureClickedListener;
private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
private boolean mUseGreenBackground = false;
- private boolean mForceNames = false;
+ private final boolean mForceNames;
- public MessageAdapter(XmppActivity activity, List<Message> messages) {
+ public MessageAdapter(final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
super(activity, 0, messages);
this.audioPlayer = new AudioPlayer(this);
this.activity = activity;
metrics = getContext().getResources().getDisplayMetrics();
updatePreferences();
+ this.mForceNames = forceNames;
}
- public MessageAdapter(XmppActivity activity, List<Message> messages, boolean forceNames) {
- this(activity, messages);
- mForceNames = forceNames;
+ public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
+ this(activity, messages, false);
}
private static void resetClickListener(View... views) {
@@ -35,7 +35,15 @@ import android.text.Editable;
import android.text.style.URLSpan;
import android.text.util.Linkify;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
import java.util.Locale;
+import java.util.Objects;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.ListItem;
@@ -144,4 +152,33 @@ public class MyLinkify {
}
}
}
+
+ public static List<String> extractLinks(final Editable body) {
+ MyLinkify.addLinks(body, false);
+ final Collection<URLSpan> spans =
+ Arrays.asList(body.getSpans(0, body.length() - 1, URLSpan.class));
+ final Collection<UrlWrapper> urlWrappers =
+ Collections2.filter(
+ Collections2.transform(
+ spans,
+ s ->
+ s == null
+ ? null
+ : new UrlWrapper(body.getSpanStart(s), s.getURL())),
+ uw -> uw != null);
+ List<UrlWrapper> sorted = ImmutableList.sortedCopyOf(
+ (a, b) -> Integer.compare(a.position, b.position), urlWrappers);
+ return Lists.transform(sorted, uw -> uw.url);
+
+ }
+
+ private static class UrlWrapper {
+ private final int position;
+ private final String url;
+
+ private UrlWrapper(int position, String url) {
+ this.position = position;
+ this.url = url;
+ }
+ }
}
@@ -84,13 +84,13 @@ public class QuoteHelper {
if (isPositionQuoteStart(line, 0)) {
int nestingDepth = 1;
for (int i = 1; i < line.length(); i++) {
- if (isPositionQuoteStart(line, i)) {
+ if (isPositionQuoteCharacter(line, i)) {
nestingDepth++;
- }
- if (nestingDepth > (Config.QUOTING_MAX_DEPTH - 1)) {
- return true;
+ } else if (line.charAt(i) != ' ') {
+ break;
}
}
+ return nestingDepth >= (Config.QUOTING_MAX_DEPTH);
}
return false;
}
@@ -158,4 +158,17 @@ public class ShareUtil {
}
return false;
}
+
+ public static String getLinkScheme(final SpannableStringBuilder body) {
+ MyLinkify.addLinks(body, false);
+ for (final String url : MyLinkify.extractLinks(body)) {
+ final Uri uri = Uri.parse(url);
+ if ("xmpp".equals(uri.getScheme())) {
+ return uri.getScheme();
+ } else {
+ return "http";
+ }
+ }
+ return null;
+ }
}
@@ -145,7 +145,13 @@ public class EditMessage extends AppCompatEditText {
public void insertAsQuote(String text) {
text = QuoteHelper.replaceAltQuoteCharsInText(text);
- text = text.replaceAll("(\n *){2,}", "\n").replaceAll("(^|\n)(" + QuoteHelper.QUOTE_CHAR + ")", "$1$2$2").replaceAll("(^|\n)([^" + QuoteHelper.QUOTE_CHAR + "])", "$1> $2").replaceAll("\n$", "");
+ text = text
+ // first replace all '>' at the beginning of the line with nice and tidy '>>'
+ // for nested quoting
+ .replaceAll("(^|\n)(" + QuoteHelper.QUOTE_CHAR + ")", "$1$2$2")
+ // then find all other lines and have them start with a '> '
+ .replaceAll("(^|\n)(?!" + QuoteHelper.QUOTE_CHAR + ")(.*)", "$1> $2")
+ ;
Editable editable = getEditableText();
int position = getSelectionEnd();
if (position == -1) position = editable.length();
@@ -8,6 +8,7 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
import android.os.Build;
import android.preference.Preference;
import android.preference.PreferenceCategory;
@@ -15,6 +16,8 @@ import android.preference.PreferenceManager;
import android.util.Log;
import androidx.annotation.BoolRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import java.util.Arrays;
@@ -158,10 +161,20 @@ public class Compatibility {
@SuppressLint("UnsupportedChromeOsCameraSystemFeature")
public static boolean hasFeatureCamera(final Context context) {
final PackageManager packageManager = context.getPackageManager();
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
- return packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
- } else {
- return packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA);
+ return packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static int getRestrictBackgroundStatus(
+ @NonNull final ConnectivityManager connectivityManager) {
+ try {
+ return connectivityManager.getRestrictBackgroundStatus();
+ } catch (final Exception e) {
+ Log.d(
+ Config.LOGTAG,
+ "platform bug detected. Unable to get restrict background status",
+ e);
+ return ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED;
}
}
}
@@ -1,5 +1,7 @@
package eu.siacs.conversations.utils;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
import android.os.Bundle;
import android.util.Base64;
import android.util.Pair;
@@ -64,12 +66,12 @@ public final class CryptoHelper {
return builder.toString();
}
- public static String pronounceable(SecureRandom random) {
- final int rand = random.nextInt(4);
+ public static String pronounceable() {
+ final int rand = SECURE_RANDOM.nextInt(4);
char[] output = new char[rand * 2 + (5 - rand)];
- boolean vowel = random.nextBoolean();
+ boolean vowel = SECURE_RANDOM.nextBoolean();
for (int i = 0; i < output.length; ++i) {
- output[i] = vowel ? VOWELS[random.nextInt(VOWELS.length)] : CONSONANTS[random.nextInt(CONSONANTS.length)];
+ output[i] = vowel ? VOWELS[SECURE_RANDOM.nextInt(VOWELS.length)] : CONSONANTS[SECURE_RANDOM.nextInt(CONSONANTS.length)];
vowel = !vowel;
}
return String.valueOf(output);
@@ -122,9 +124,9 @@ public final class CryptoHelper {
return Normalizer.normalize(s, Normalizer.Form.NFKC);
}
- public static String random(int length, SecureRandom random) {
+ public static String random(final int length) {
final byte[] bytes = new byte[length];
- random.nextBytes(bytes);
+ SECURE_RANDOM.nextBytes(bytes);
return Base64.encodeToString(bytes, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE);
}
@@ -66,11 +66,7 @@ public class MessageUtils {
body = message.getMergedBody().toString();
}
for (String line : body.split("\n")) {
- if (line.length() <= 0) {
- continue;
- }
- final char c = line.charAt(0);
- if (QuoteHelper.isNestedTooDeeply(line)) {
+ if (!(line.length() <= 0) && QuoteHelper.isNestedTooDeeply(line)) {
continue;
}
if (builder.length() != 0) {
@@ -12,27 +12,51 @@ import android.provider.Settings;
public class PhoneHelper {
- @SuppressLint("HardwareIds")
- public static String getAndroidId(Context context) {
- return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
- }
+ @SuppressLint("HardwareIds")
+ public static String getAndroidId(Context context) {
+ return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
+ }
- public static Uri getProfilePictureUri(Context context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
- return null;
- }
- final String[] projection = new String[]{Profile._ID, Profile.PHOTO_URI};
- final Cursor cursor;
- try {
- cursor = context.getContentResolver().query(Profile.CONTENT_URI, projection, null, null, null);
- } catch (Throwable e) {
- return null;
- }
- if (cursor == null) {
- return null;
- }
- final String uri = cursor.moveToFirst() ? cursor.getString(1) : null;
- cursor.close();
- return uri == null ? null : Uri.parse(uri);
- }
+ public static Uri getProfilePictureUri(Context context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ && context.checkSelfPermission(Manifest.permission.READ_CONTACTS)
+ != PackageManager.PERMISSION_GRANTED) {
+ return null;
+ }
+ final String[] projection = new String[] {Profile._ID, Profile.PHOTO_URI};
+ final Cursor cursor;
+ try {
+ cursor =
+ context.getContentResolver()
+ .query(Profile.CONTENT_URI, projection, null, null, null);
+ } catch (Throwable e) {
+ return null;
+ }
+ if (cursor == null) {
+ return null;
+ }
+ final String uri = cursor.moveToFirst() ? cursor.getString(1) : null;
+ cursor.close();
+ return uri == null ? null : Uri.parse(uri);
+ }
+
+ public static boolean isEmulator() {
+ return (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
+ || Build.FINGERPRINT.startsWith("generic")
+ || Build.FINGERPRINT.startsWith("unknown")
+ || Build.HARDWARE.contains("goldfish")
+ || Build.HARDWARE.contains("ranchu")
+ || Build.MODEL.contains("google_sdk")
+ || Build.MODEL.contains("Emulator")
+ || Build.MODEL.contains("Android SDK built for x86")
+ || Build.MANUFACTURER.contains("Genymotion")
+ || Build.PRODUCT.contains("sdk_google")
+ || Build.PRODUCT.contains("google_sdk")
+ || Build.PRODUCT.contains("sdk")
+ || Build.PRODUCT.contains("sdk_x86")
+ || Build.PRODUCT.contains("sdk_gphone64_arm64")
+ || Build.PRODUCT.contains("vbox86p")
+ || Build.PRODUCT.contains("emulator")
+ || Build.PRODUCT.contains("simulator");
+ }
}
@@ -0,0 +1,13 @@
+package eu.siacs.conversations.utils;
+
+import java.security.SecureRandom;
+
+public final class Random {
+
+ public static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private Random() {
+
+ }
+
+}
@@ -5,9 +5,12 @@ import android.util.Log;
import androidx.annotation.RequiresApi;
+import com.google.common.base.Strings;
+
import org.conscrypt.Conscrypt;
import java.lang.reflect.Method;
+import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
@@ -24,7 +27,7 @@ import javax.net.ssl.SSLSocket;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Account;
-public class SSLSocketHelper {
+public class SSLSockets {
public static void setSecurity(final SSLSocket sslSocket) {
final String[] supportProtocols;
@@ -100,6 +103,45 @@ public class SSLSocketHelper {
public static void log(Account account, SSLSocket socket) {
SSLSession session = socket.getSession();
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": protocol=" + session.getProtocol() + " cipher=" + session.getCipherSuite());
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": protocol="
+ + session.getProtocol()
+ + " cipher="
+ + session.getCipherSuite());
+ }
+
+ public static Version version(final Socket socket) {
+ if (socket instanceof SSLSocket) {
+ final SSLSocket sslSocket = (SSLSocket) socket;
+ return Version.of(sslSocket.getSession().getProtocol());
+ } else {
+ return Version.NONE;
+ }
+ }
+
+ public enum Version {
+ TLS_1_0,
+ TLS_1_1,
+ TLS_1_2,
+ TLS_1_3,
+ UNKNOWN,
+ NONE;
+
+ private static Version of(final String protocol) {
+ switch (Strings.nullToEmpty(protocol)) {
+ case "TLSv1":
+ return TLS_1_0;
+ case "TLSv1.1":
+ return TLS_1_1;
+ case "TLSv1.2":
+ return TLS_1_2;
+ case "TLSv1.3":
+ return TLS_1_3;
+ default:
+ return UNKNOWN;
+ }
+ }
}
}
@@ -17,7 +17,7 @@ public class TLSSocketFactory extends SSLSocketFactory {
private final SSLSocketFactory internalSSLSocketFactory;
public TLSSocketFactory(X509TrustManager[] trustManager, SecureRandom random) throws KeyManagementException, NoSuchAlgorithmException {
- SSLContext context = SSLSocketHelper.getSSLContext();
+ SSLContext context = SSLSockets.getSSLContext();
context.init(null, trustManager, random);
this.internalSSLSocketFactory = context.getSocketFactory();
}
@@ -59,7 +59,7 @@ public class TLSSocketFactory extends SSLSocketFactory {
private static Socket enableTLSOnSocket(Socket socket) {
if(socket instanceof SSLSocket) {
- SSLSocketHelper.setSecurity((SSLSocket) socket);
+ SSLSockets.setSecurity((SSLSocket) socket);
}
return socket;
}
@@ -1,30 +1,31 @@
package eu.siacs.conversations.utils;
+import com.google.common.base.Joiner;
+import com.google.common.collect.Lists;
+
+import java.util.Collections;
+import java.util.List;
+
import eu.siacs.conversations.xml.Element;
public class XmlHelper {
- public static String encodeEntities(String content) {
- content = content.replace("&", "&");
- content = content.replace("<", "<");
- content = content.replace(">", ">");
- content = content.replace("\"", """);
- content = content.replace("'", "'");
- content = content.replaceAll("[\\p{Cntrl}&&[^\n\t\r]]", "");
- return content;
- }
+ public static String encodeEntities(String content) {
+ content = content.replace("&", "&");
+ content = content.replace("<", "<");
+ content = content.replace(">", ">");
+ content = content.replace("\"", """);
+ content = content.replace("'", "'");
+ content = content.replaceAll("[\\p{Cntrl}&&[^\n\t\r]]", "");
+ return content;
+ }
- public static String printElementNames(final Element element) {
- final StringBuilder builder = new StringBuilder();
- builder.append('[');
- if (element != null) {
- for (Element child : element.getChildren()) {
- if (builder.length() != 1) {
- builder.append(',');
- }
- builder.append(child.getName());
- }
- }
- builder.append(']');
- return builder.toString();
- }
+ public static String printElementNames(final Element element) {
+ final List<String> features =
+ element == null
+ ? Collections.emptyList()
+ : Lists.transform(
+ element.getChildren(),
+ child -> child != null ? child.getName() : null);
+ return Joiner.on(", ").join(features);
+ }
}
@@ -211,11 +211,11 @@ public class Element implements Node {
final StringBuilder elementOutput = new StringBuilder();
if (childNodes.size() == 0) {
Tag emptyTag = Tag.empty(name);
- emptyTag.setAtttributes(this.attributes);
+ emptyTag.setAttributes(this.attributes);
elementOutput.append(emptyTag.toString());
} else {
Tag startTag = Tag.start(name);
- startTag.setAtttributes(this.attributes);
+ startTag.setAttributes(this.attributes);
elementOutput.append(startTag);
for (Node child : childNodes) {
elementOutput.append(child.toString());
@@ -1,12 +1,14 @@
package eu.siacs.conversations.xml;
public final class Namespace {
+ public static final String STREAMS = "http://etherx.jabber.org/streams";
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 REGISTER_STREAM_FEATURE = "http://jabber.org/features/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";
@@ -15,6 +17,9 @@ public final class Namespace {
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 SASL_2 = "urn:xmpp:sasl:2";
+ public static final String CHANNEL_BINDING = "urn:xmpp:sasl-cb:0";
+ public static final String FAST = "urn:xmpp:fast:0";
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";
@@ -23,9 +28,15 @@ public final class Namespace {
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 BIND2 = "urn:xmpp:bind:0";
+ public static final String STREAM_MANAGEMENT = "urn:xmpp:sm:3";
+ public static final String CSI = "urn:xmpp:csi:0";
+ public static final String CARBONS = "urn:xmpp:carbons:2";
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_DATA = "urn:xmpp:avatar:data";
+ public static final String AVATAR_METADATA = "urn:xmpp:avatar:metadata";
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_ERRORS = "urn:xmpp:jingle:errors:1";
@@ -1,104 +1,107 @@
package eu.siacs.conversations.xml;
+import org.jetbrains.annotations.NotNull;
+
import java.util.Hashtable;
-import java.util.Iterator;
import java.util.Map.Entry;
import java.util.Set;
import eu.siacs.conversations.utils.XmlHelper;
public class Tag {
- public static final int NO = -1;
- public static final int START = 0;
- public static final int END = 1;
- public static final int EMPTY = 2;
-
- protected int type;
- protected String name;
- protected Hashtable<String, String> attributes = new Hashtable<String, String>();
-
- protected Tag(int type, String name) {
- this.type = type;
- this.name = name;
- }
-
- public static Tag no(String text) {
- return new Tag(NO, text);
- }
-
- public static Tag start(String name) {
- return new Tag(START, name);
- }
-
- public static Tag end(String name) {
- return new Tag(END, name);
- }
-
- public static Tag empty(String name) {
- return new Tag(EMPTY, name);
- }
-
- public String getName() {
- return name;
- }
-
- public String getAttribute(String attrName) {
- return this.attributes.get(attrName);
- }
-
- public Tag setAttribute(String attrName, String attrValue) {
- this.attributes.put(attrName, attrValue);
- return this;
- }
-
- public Tag setAtttributes(Hashtable<String, String> attributes) {
- this.attributes = attributes;
- return this;
- }
-
- public boolean isStart(String needle) {
- if (needle == null)
- return false;
- return (this.type == START) && (needle.equals(this.name));
- }
-
- public boolean isEnd(String needle) {
- if (needle == null)
- return false;
- return (this.type == END) && (needle.equals(this.name));
- }
-
- public boolean isNo() {
- return (this.type == NO);
- }
-
- public String toString() {
- StringBuilder tagOutput = new StringBuilder();
- tagOutput.append('<');
- if (type == END) {
- tagOutput.append('/');
- }
- tagOutput.append(name);
- if (type != END) {
- Set<Entry<String, String>> attributeSet = attributes.entrySet();
- Iterator<Entry<String, String>> it = attributeSet.iterator();
- while (it.hasNext()) {
- Entry<String, String> entry = it.next();
- tagOutput.append(' ');
- tagOutput.append(entry.getKey());
- tagOutput.append("=\"");
- tagOutput.append(XmlHelper.encodeEntities(entry.getValue()));
- tagOutput.append('"');
- }
- }
- if (type == EMPTY) {
- tagOutput.append('/');
- }
- tagOutput.append('>');
- return tagOutput.toString();
- }
-
- public Hashtable<String, String> getAttributes() {
- return this.attributes;
- }
+ public static final int NO = -1;
+ public static final int START = 0;
+ public static final int END = 1;
+ public static final int EMPTY = 2;
+
+ protected int type;
+ protected String name;
+ protected Hashtable<String, String> attributes = new Hashtable<String, String>();
+
+ protected Tag(int type, String name) {
+ this.type = type;
+ this.name = name;
+ }
+
+ public static Tag no(String text) {
+ return new Tag(NO, text);
+ }
+
+ public static Tag start(String name) {
+ return new Tag(START, name);
+ }
+
+ public static Tag end(String name) {
+ return new Tag(END, name);
+ }
+
+ public static Tag empty(String name) {
+ return new Tag(EMPTY, name);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getAttribute(final String attrName) {
+ return this.attributes.get(attrName);
+ }
+
+ public Tag setAttribute(final String attrName, final String attrValue) {
+ this.attributes.put(attrName, attrValue);
+ return this;
+ }
+
+ public void setAttributes(final Hashtable<String, String> attributes) {
+ this.attributes = attributes;
+ }
+
+ public boolean isStart(final String needle) {
+ if (needle == null) {
+ return false;
+ }
+ return (this.type == START) && (needle.equals(this.name));
+ }
+
+ public boolean isStart(final String name, final String namespace) {
+ return isStart(name) && namespace != null && namespace.equals(this.getAttribute("xmlns"));
+ }
+
+ public boolean isEnd(String needle) {
+ if (needle == null) return false;
+ return (this.type == END) && (needle.equals(this.name));
+ }
+
+ public boolean isNo() {
+ return (this.type == NO);
+ }
+
+ @NotNull
+ public String toString() {
+ final StringBuilder tagOutput = new StringBuilder();
+ tagOutput.append('<');
+ if (type == END) {
+ tagOutput.append('/');
+ }
+ tagOutput.append(name);
+ if (type != END) {
+ final Set<Entry<String, String>> attributeSet = attributes.entrySet();
+ for (final Entry<String, String> entry : attributeSet) {
+ tagOutput.append(' ');
+ tagOutput.append(entry.getKey());
+ tagOutput.append("=\"");
+ tagOutput.append(XmlHelper.encodeEntities(entry.getValue()));
+ tagOutput.append('"');
+ }
+ }
+ if (type == EMPTY) {
+ tagOutput.append('/');
+ }
+ tagOutput.append('>');
+ return tagOutput.toString();
+ }
+
+ public Hashtable<String, String> getAttributes() {
+ return this.attributes;
+ }
}
@@ -58,15 +58,20 @@ public class TagWriter {
throw new IOException("output stream was null");
}
outputStream.write("<?xml version='1.0'?>");
- outputStream.flush();
}
- public synchronized void writeTag(Tag tag) throws IOException {
+ public void writeTag(final Tag tag) throws IOException {
+ writeTag(tag, true);
+ }
+
+ public synchronized void writeTag(final Tag tag, final boolean flush) throws IOException {
if (outputStream == null) {
throw new IOException("output stream was null");
}
outputStream.write(tag.toString());
- outputStream.flush();
+ if (flush) {
+ outputStream.flush();
+ }
}
public synchronized void writeElement(Element element) throws IOException {
@@ -118,8 +118,7 @@ public interface Jid extends Comparable<Jid>, Serializable, CharSequence {
static Jid ofEscaped(CharSequence jid) {
try {
return new WrappedJid(JidCreate.from(jid));
- } catch (XmppStringprepException e) {
- e.printStackTrace();
+ } catch (final XmppStringprepException e) {
throw new IllegalArgumentException(e);
}
}
@@ -1,8 +1,11 @@
package eu.siacs.conversations.xmpp;
+import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
+
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
+import android.os.Build;
import android.os.SystemClock;
import android.security.KeyChain;
import android.util.Base64;
@@ -11,6 +14,7 @@ import android.util.Pair;
import android.util.SparseArray;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import com.google.common.base.Strings;
@@ -32,6 +36,7 @@ import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -58,14 +63,9 @@ import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.XmppDomainVerifier;
import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.crypto.sasl.Anonymous;
-import eu.siacs.conversations.crypto.sasl.DigestMd5;
-import eu.siacs.conversations.crypto.sasl.External;
-import eu.siacs.conversations.crypto.sasl.Plain;
+import eu.siacs.conversations.crypto.sasl.ChannelBinding;
+import eu.siacs.conversations.crypto.sasl.HashedToken;
import eu.siacs.conversations.crypto.sasl.SaslMechanism;
-import eu.siacs.conversations.crypto.sasl.ScramSha1;
-import eu.siacs.conversations.crypto.sasl.ScramSha256;
-import eu.siacs.conversations.crypto.sasl.ScramSha512;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.ServiceDiscoveryResult;
@@ -78,8 +78,9 @@ import eu.siacs.conversations.services.NotificationService;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.Patterns;
+import eu.siacs.conversations.utils.PhoneHelper;
import eu.siacs.conversations.utils.Resolver;
-import eu.siacs.conversations.utils.SSLSocketHelper;
+import eu.siacs.conversations.utils.SSLSockets;
import eu.siacs.conversations.utils.SocksSocketFactory;
import eu.siacs.conversations.utils.XmlHelper;
import eu.siacs.conversations.xml.Element;
@@ -88,6 +89,7 @@ import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xml.Tag;
import eu.siacs.conversations.xml.TagWriter;
import eu.siacs.conversations.xml.XmlReader;
+import eu.siacs.conversations.xmpp.bind.Bind2;
import eu.siacs.conversations.xmpp.forms.Data;
import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
@@ -109,48 +111,55 @@ public class XmppConnection implements Runnable {
private static final int PACKET_IQ = 0;
private static final int PACKET_MESSAGE = 1;
private static final int PACKET_PRESENCE = 2;
- public final OnIqPacketReceived registrationResponseListener = (account, packet) -> {
- if (packet.getType() == IqPacket.TYPE.RESULT) {
- account.setOption(Account.OPTION_REGISTER, false);
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": successfully registered new account on server");
- throw new StateChangingError(Account.State.REGISTRATION_SUCCESSFUL);
- } else {
- final List<String> PASSWORD_TOO_WEAK_MSGS = Arrays.asList(
- "The password is too weak",
- "Please use a longer password.");
- Element error = packet.findChild("error");
- Account.State state = Account.State.REGISTRATION_FAILED;
- if (error != null) {
- if (error.hasChild("conflict")) {
- state = Account.State.REGISTRATION_CONFLICT;
- } else if (error.hasChild("resource-constraint")
- && "wait".equals(error.getAttribute("type"))) {
- state = Account.State.REGISTRATION_PLEASE_WAIT;
- } else if (error.hasChild("not-acceptable")
- && PASSWORD_TOO_WEAK_MSGS.contains(error.findChildContent("text"))) {
- state = Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
+ public final OnIqPacketReceived registrationResponseListener =
+ (account, packet) -> {
+ if (packet.getType() == IqPacket.TYPE.RESULT) {
+ account.setOption(Account.OPTION_REGISTER, false);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": successfully registered new account on server");
+ throw new StateChangingError(Account.State.REGISTRATION_SUCCESSFUL);
+ } else {
+ final List<String> PASSWORD_TOO_WEAK_MSGS =
+ Arrays.asList(
+ "The password is too weak", "Please use a longer password.");
+ Element error = packet.findChild("error");
+ Account.State state = Account.State.REGISTRATION_FAILED;
+ if (error != null) {
+ if (error.hasChild("conflict")) {
+ state = Account.State.REGISTRATION_CONFLICT;
+ } else if (error.hasChild("resource-constraint")
+ && "wait".equals(error.getAttribute("type"))) {
+ state = Account.State.REGISTRATION_PLEASE_WAIT;
+ } else if (error.hasChild("not-acceptable")
+ && PASSWORD_TOO_WEAK_MSGS.contains(
+ error.findChildContent("text"))) {
+ state = Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
+ }
+ }
+ throw new StateChangingError(state);
}
- }
- throw new StateChangingError(state);
- }
- };
+ };
protected final Account account;
private final Features features = new Features(this);
private final HashMap<Jid, ServiceDiscoveryResult> disco = new HashMap<>();
private final HashMap<String, Jid> commands = new HashMap<>();
private final SparseArray<AbstractAcknowledgeableStanza> mStanzaQueue = new SparseArray<>();
- private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks = new Hashtable<>();
- private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners = new HashSet<>();
+ private final Hashtable<String, Pair<IqPacket, OnIqPacketReceived>> packetCallbacks =
+ new Hashtable<>();
+ private final Set<OnAdvancedStreamFeaturesLoaded> advancedStreamFeaturesLoadedListeners =
+ new HashSet<>();
private final XmppConnectionService mXmppConnectionService;
private Socket socket;
private XmlReader tagReader;
private TagWriter tagWriter = new TagWriter();
private boolean shouldAuthenticate = true;
private boolean inSmacksSession = false;
+ private boolean quickStartInProgress = false;
private boolean isBound = false;
private Element streamFeatures;
private String streamId = null;
- private int smVersion = 3;
private int stanzasReceived = 0;
private int stanzasSent = 0;
private long lastPacketReceived = 0;
@@ -173,12 +182,12 @@ public class XmppConnection implements Runnable {
private OnBindListener bindListener = null;
private OnMessageAcknowledged acknowledgedListener = null;
private SaslMechanism saslMechanism;
+ private HashedToken.Mechanism hashTokenRequest;
private HttpUrl redirectionUrl = null;
private String verifiedHostname = null;
private volatile Thread mThread;
private CountDownLatch mStreamCountDownLatch;
-
public XmppConnection(final Account account, final XmppConnectionService service) {
this.account = account;
this.mXmppConnectionService = service;
@@ -186,10 +195,12 @@ public class XmppConnection implements Runnable {
private static void fixResource(Context context, Account account) {
String resource = account.getResource();
- int fixedPartLength = context.getString(R.string.app_name).length() + 1; //include the trailing dot
+ int fixedPartLength =
+ context.getString(R.string.app_name).length() + 1; // include the trailing dot
int randomPartLength = 4; // 3 bytes
if (resource != null && resource.length() > fixedPartLength + randomPartLength) {
- if (validBase64(resource.substring(fixedPartLength, fixedPartLength + randomPartLength))) {
+ if (validBase64(
+ resource.substring(fixedPartLength, fixedPartLength + randomPartLength))) {
account.setResource(resource.substring(0, fixedPartLength + randomPartLength));
}
}
@@ -206,7 +217,12 @@ public class XmppConnection implements Runnable {
private void changeStatus(final Account.State nextStatus) {
synchronized (this) {
if (Thread.currentThread().isInterrupted()) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": not changing status to " + nextStatus + " because thread was interrupted");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": not changing status to "
+ + nextStatus
+ + " because thread was interrupted");
return;
}
if (account.getStatus() != nextStatus) {
@@ -257,10 +273,12 @@ public class XmppConnection implements Runnable {
}
Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": connecting");
features.encryptionEnabled = false;
- inSmacksSession = false;
- isBound = false;
+ this.inSmacksSession = false;
+ this.quickStartInProgress = false;
+ this.isBound = false;
this.attempt++;
- this.verifiedHostname = null; //will be set if user entered hostname is being used or hostname was verified with dnssec
+ this.verifiedHostname = null; // will be set if user entered hostname is being used or hostname was verified
+ // with dnssec
try {
Socket localSocket;
shouldAuthenticate = !account.isOptionSet(Account.OPTION_REGISTER);
@@ -279,7 +297,13 @@ public class XmppConnection implements Runnable {
final int port = account.getPort();
final boolean directTls = Resolver.useDirectTls(port);
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": connect to " + destination + " via Tor. directTls=" + directTls);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": connect to "
+ + destination
+ + " via Tor. directTls="
+ + directTls);
localSocket = SocksSocketFactory.createSocketOverTor(destination, port);
if (directTls) {
@@ -289,11 +313,14 @@ public class XmppConnection implements Runnable {
try {
startXmpp(localSocket);
- } catch (InterruptedException e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": thread was interrupted before beginning stream");
+ } catch (final InterruptedException e) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": thread was interrupted before beginning stream");
return;
- } catch (Exception e) {
- throw new IOException(e.getMessage());
+ } catch (final Exception e) {
+ throw new IOException("Could not start stream", e);
}
} else {
final String domain = account.getServer();
@@ -309,41 +336,70 @@ public class XmppConnection implements Runnable {
return;
}
if (results.size() == 0) {
- Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": Resolver results were empty");
+ Log.e(
+ Config.LOGTAG,
+ account.getJid().asBareJid() + ": Resolver results were empty");
return;
}
final Resolver.Result storedBackupResult;
if (hardcoded) {
storedBackupResult = null;
} else {
- storedBackupResult = mXmppConnectionService.databaseBackend.findResolverResult(domain);
+ storedBackupResult =
+ mXmppConnectionService.databaseBackend.findResolverResult(domain);
if (storedBackupResult != null && !results.contains(storedBackupResult)) {
results.add(storedBackupResult);
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": loaded backup resolver result from db: " + storedBackupResult);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": loaded backup resolver result from db: "
+ + storedBackupResult);
}
}
- for (Iterator<Resolver.Result> iterator = results.iterator(); iterator.hasNext(); ) {
+ for (Iterator<Resolver.Result> iterator = results.iterator();
+ iterator.hasNext(); ) {
final Resolver.Result result = iterator.next();
if (Thread.currentThread().isInterrupted()) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": Thread was interrupted");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid() + ": Thread was interrupted");
return;
}
try {
// if tls is true, encryption is implied and must not be started
features.encryptionEnabled = result.isDirectTls();
- verifiedHostname = result.isAuthenticated() ? result.getHostname().toString() : null;
+ verifiedHostname =
+ result.isAuthenticated() ? result.getHostname().toString() : null;
Log.d(Config.LOGTAG, "verified hostname " + verifiedHostname);
final InetSocketAddress addr;
if (result.getIp() != null) {
addr = new InetSocketAddress(result.getIp(), result.getPort());
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
- + ": using values from resolver " + (result.getHostname() == null ? "" : result.getHostname().toString()
- + "/") + result.getIp().getHostAddress() + ":" + result.getPort() + " tls: " + features.encryptionEnabled);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid().toString()
+ + ": using values from resolver "
+ + (result.getHostname() == null
+ ? ""
+ : result.getHostname().toString() + "/")
+ + result.getIp().getHostAddress()
+ + ":"
+ + result.getPort()
+ + " tls: "
+ + features.encryptionEnabled);
} else {
- addr = new InetSocketAddress(IDN.toASCII(result.getHostname().toString()), result.getPort());
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
- + ": using values from resolver "
- + result.getHostname().toString() + ":" + result.getPort() + " tls: " + features.encryptionEnabled);
+ addr =
+ new InetSocketAddress(
+ IDN.toASCII(result.getHostname().toString()),
+ result.getPort());
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid().toString()
+ + ": using values from resolver "
+ + result.getHostname().toString()
+ + ":"
+ + result.getPort()
+ + " tls: "
+ + features.encryptionEnabled);
}
localSocket = new Socket();
@@ -355,9 +411,12 @@ public class XmppConnection implements Runnable {
localSocket.setSoTimeout(Config.SOCKET_TIMEOUT * 1000);
if (startXmpp(localSocket)) {
- localSocket.setSoTimeout(0); //reset to 0; once the connection is established we don’t want this
+ localSocket.setSoTimeout(
+ 0); // reset to 0; once the connection is established we don’t
+ // want this
if (!hardcoded && !result.equals(storedBackupResult)) {
- mXmppConnectionService.databaseBackend.saveResolverResult(domain, result);
+ mXmppConnectionService.databaseBackend.saveResolverResult(
+ domain, result);
}
break; // successfully connected to server that speaks xmpp
} else {
@@ -369,10 +428,20 @@ public class XmppConnection implements Runnable {
throw e;
}
} catch (InterruptedException e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": thread was interrupted before beginning stream");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": thread was interrupted before beginning stream");
return;
} catch (final Throwable e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": " + e.getMessage() + "(" + e.getClass().getName() + ")");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid().toString()
+ + ": "
+ + e.getMessage()
+ + "("
+ + e.getClass().getName()
+ + ")");
if (!iterator.hasNext()) {
throw new UnknownHostException();
}
@@ -384,7 +453,9 @@ public class XmppConnection implements Runnable {
this.changeStatus(Account.State.MISSING_INTERNET_PERMISSION);
} catch (final StateChangingException e) {
this.changeStatus(e.state);
- } catch (final UnknownHostException | ConnectException | SocksSocketFactory.HostNotFoundException e) {
+ } catch (final UnknownHostException
+ | ConnectException
+ | SocksSocketFactory.HostNotFoundException e) {
this.changeStatus(Account.State.SERVER_NOT_FOUND);
} catch (final SocksSocketFactory.SocksProxyNotFoundException e) {
this.changeStatus(Account.State.TOR_NOT_AVAILABLE);
@@ -396,7 +467,10 @@ public class XmppConnection implements Runnable {
if (!Thread.currentThread().isInterrupted()) {
forceCloseSocket();
} else {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": not force closing socket because thread was interrupted");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": not force closing socket because thread was interrupted");
}
}
}
@@ -406,7 +480,7 @@ public class XmppConnection implements Runnable {
*
* @return true if server returns with valid xmpp, false otherwise
*/
- private boolean startXmpp(Socket socket) throws Exception {
+ private boolean startXmpp(final Socket socket) throws Exception {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException();
}
@@ -419,28 +493,45 @@ public class XmppConnection implements Runnable {
tagWriter.setOutputStream(socket.getOutputStream());
tagReader.setInputStream(socket.getInputStream());
tagWriter.beginDocument();
- sendStartStream();
+ final boolean quickStart;
+ if (socket instanceof SSLSocket) {
+ final SSLSocket sslSocket = (SSLSocket) socket;
+ SSLSockets.log(account, sslSocket);
+ quickStart = establishStream(SSLSockets.version(sslSocket));
+ } else {
+ quickStart = establishStream(SSLSockets.Version.NONE);
+ }
final Tag tag = tagReader.readTag();
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException();
}
- if (socket instanceof SSLSocket) {
- SSLSocketHelper.log(account, (SSLSocket) socket);
+ final boolean success = tag != null && tag.isStart("stream", Namespace.STREAMS);
+ if (success && quickStart) {
+ this.quickStartInProgress = true;
}
- return tag != null && tag.isStart("stream");
+ return success;
}
- private SSLSocketFactory getSSLSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
- final SSLContext sc = SSLSocketHelper.getSSLContext();
- final MemorizingTrustManager trustManager = this.mXmppConnectionService.getMemorizingTrustManager();
+ private SSLSocketFactory getSSLSocketFactory()
+ throws NoSuchAlgorithmException, KeyManagementException {
+ final SSLContext sc = SSLSockets.getSSLContext();
+ final MemorizingTrustManager trustManager =
+ this.mXmppConnectionService.getMemorizingTrustManager();
final KeyManager[] keyManager;
if (account.getPrivateKeyAlias() != null) {
- keyManager = new KeyManager[]{new MyKeyManager()};
+ keyManager = new KeyManager[] {new MyKeyManager()};
} else {
keyManager = null;
}
final String domain = account.getServer();
- sc.init(keyManager, new X509TrustManager[]{mInteractive ? trustManager.getInteractive(domain) : trustManager.getNonInteractive(domain)}, mXmppConnectionService.getRNG());
+ sc.init(
+ keyManager,
+ new X509TrustManager[] {
+ mInteractive
+ ? trustManager.getInteractive(domain)
+ : trustManager.getNonInteractive(domain)
+ },
+ SECURE_RANDOM);
return sc.getSocketFactory();
}
@@ -449,7 +540,10 @@ public class XmppConnection implements Runnable {
synchronized (this) {
this.mThread = Thread.currentThread();
if (this.mThread.isInterrupted()) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": aborting connect because thread was interrupted");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": aborting connect because thread was interrupted");
return;
}
forceCloseSocket();
@@ -464,134 +558,51 @@ public class XmppConnection implements Runnable {
while (nextTag != null && !nextTag.isEnd("stream")) {
if (nextTag.isStart("error")) {
processStreamError(nextTag);
- } else if (nextTag.isStart("features")) {
+ } else if (nextTag.isStart("features", Namespace.STREAMS)) {
processStreamFeatures(nextTag);
- } else if (nextTag.isStart("proceed")) {
+ } else if (nextTag.isStart("proceed", Namespace.TLS)) {
switchOverToTls();
} else if (nextTag.isStart("success")) {
- final String challenge = tagReader.readElement(nextTag).getContent();
- try {
- saslMechanism.getResponse(challenge);
- } catch (final SaslMechanism.AuthenticationException e) {
- Log.e(Config.LOGTAG, String.valueOf(e));
- throw new StateChangingException(Account.State.UNAUTHORIZED);
+ final Element success = tagReader.readElement(nextTag);
+ if (processSuccess(success)) {
+ break;
}
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": logged in");
- account.setKey(Account.PINNED_MECHANISM_KEY,
- String.valueOf(saslMechanism.getPriority()));
- tagReader.reset();
- sendStartStream();
- final Tag tag = tagReader.readTag();
- if (tag != null && tag.isStart("stream")) {
- processStream();
- } else {
- throw new StateChangingException(Account.State.STREAM_OPENING_ERROR);
- }
- break;
+
+ } else if (nextTag.isStart("failure", Namespace.TLS)) {
+ throw new StateChangingException(Account.State.TLS_ERROR);
} else if (nextTag.isStart("failure")) {
final Element failure = tagReader.readElement(nextTag);
- if (Namespace.SASL.equals(failure.getNamespace())) {
- if (failure.hasChild("temporary-auth-failure")) {
- throw new StateChangingException(Account.State.TEMPORARY_AUTH_FAILURE);
- } else if (failure.hasChild("account-disabled")) {
- final String text = failure.findChildContent("text");
- if ( Strings.isNullOrEmpty(text)) {
- throw new StateChangingException(Account.State.UNAUTHORIZED);
- }
- final Matcher matcher = Patterns.AUTOLINK_WEB_URL.matcher(text);
- if (matcher.find()) {
- final HttpUrl url;
- try {
- url = HttpUrl.get(text.substring(matcher.start(), matcher.end()));
- } catch (final IllegalArgumentException e) {
- throw new StateChangingException(Account.State.UNAUTHORIZED);
- }
- if (url.isHttps()) {
- this.redirectionUrl = url;
- throw new StateChangingException(Account.State.PAYMENT_REQUIRED);
- }
- }
- }
- throw new StateChangingException(Account.State.UNAUTHORIZED);
- } else if (Namespace.TLS.equals(failure.getNamespace())) {
- throw new StateChangingException(Account.State.TLS_ERROR);
- } else {
- throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
- }
+ processFailure(failure);
+ } else if (nextTag.isStart("continue", Namespace.SASL_2)) {
+ // two step sasl2 - we don’t support this yet
+ throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT);
} else if (nextTag.isStart("challenge")) {
- final String challenge = tagReader.readElement(nextTag).getContent();
- final Element response = new Element("response", Namespace.SASL);
- try {
- response.setContent(saslMechanism.getResponse(challenge));
- } catch (final SaslMechanism.AuthenticationException e) {
- // TODO: Send auth abort tag.
- Log.e(Config.LOGTAG, e.toString());
- }
- tagWriter.writeElement(response);
- } else if (nextTag.isStart("enabled")) {
- final Element enabled = tagReader.readElement(nextTag);
- if ("true".equals(enabled.getAttribute("resume"))) {
- this.streamId = enabled.getAttribute("id");
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
- + ": stream management(" + smVersion
- + ") enabled (resumable)");
+ if (isSecure() && this.saslMechanism != null) {
+ final Element challenge = tagReader.readElement(nextTag);
+ processChallenge(challenge);
} else {
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
- + ": stream management(" + smVersion + ") enabled");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": received 'challenge on an unsecure connection");
+ throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT);
}
- this.stanzasReceived = 0;
- this.inSmacksSession = true;
- final RequestPacket r = new RequestPacket(smVersion);
- tagWriter.writeStanzaAsync(r);
+ } else if (nextTag.isStart("enabled", Namespace.STREAM_MANAGEMENT)) {
+ final Element enabled = tagReader.readElement(nextTag);
+ processEnabled(enabled);
} else if (nextTag.isStart("resumed")) {
- this.inSmacksSession = true;
- this.isBound = true;
- this.tagWriter.writeStanzaAsync(new RequestPacket(smVersion));
- lastPacketReceived = SystemClock.elapsedRealtime();
final Element resumed = tagReader.readElement(nextTag);
- final String h = resumed.getAttribute("h");
- try {
- ArrayList<AbstractAcknowledgeableStanza> failedStanzas = new ArrayList<>();
- final boolean acknowledgedMessages;
- synchronized (this.mStanzaQueue) {
- final int serverCount = Integer.parseInt(h);
- if (serverCount < stanzasSent) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString()
- + ": session resumed with lost packages");
- stanzasSent = serverCount;
- } else {
- Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": session resumed");
- }
- acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
- for (int i = 0; i < this.mStanzaQueue.size(); ++i) {
- failedStanzas.add(mStanzaQueue.valueAt(i));
- }
- mStanzaQueue.clear();
- }
- if (acknowledgedMessages) {
- mXmppConnectionService.updateConversationUi();
- }
- Log.d(Config.LOGTAG, "resending " + failedStanzas.size() + " stanzas");
- for (AbstractAcknowledgeableStanza packet : failedStanzas) {
- if (packet instanceof MessagePacket) {
- MessagePacket message = (MessagePacket) packet;
- mXmppConnectionService.markMessage(account,
- message.getTo().asBareJid(),
- message.getId(),
- Message.STATUS_UNSEND);
- }
- sendPacket(packet);
- }
- } catch (final NumberFormatException ignored) {
- }
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": online with resource " + account.getResource());
- changeStatus(Account.State.ONLINE);
+ processResumed(resumed);
} else if (nextTag.isStart("r")) {
tagReader.readElement(nextTag);
if (Config.EXTENDED_SM_LOGGING) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": acknowledging stanza #" + this.stanzasReceived);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": acknowledging stanza #"
+ + this.stanzasReceived);
}
- final AckPacket ack = new AckPacket(this.stanzasReceived, smVersion);
+ final AckPacket ack = new AckPacket(this.stanzasReceived);
tagWriter.writeStanzaAsync(ack);
} else if (nextTag.isStart("a")) {
boolean accountUiNeedsRefresh = false;
@@ -599,10 +610,19 @@ public class XmppConnection implements Runnable {
if (mWaitingForSmCatchup.compareAndSet(true, false)) {
final int messageCount = mSmCatchupMessageCounter.get();
final int pendingIQs = packetCallbacks.size();
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": SM catchup complete (messages=" + messageCount + ", pending IQs=" + pendingIQs + ")");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": SM catchup complete (messages="
+ + messageCount
+ + ", pending IQs="
+ + pendingIQs
+ + ")");
accountUiNeedsRefresh = true;
if (messageCount > 0) {
- mXmppConnectionService.getNotificationService().finishBacklog(true, account);
+ mXmppConnectionService
+ .getNotificationService()
+ .finishBacklog(true, account);
}
}
}
@@ -621,25 +641,14 @@ public class XmppConnection implements Runnable {
mXmppConnectionService.updateConversationUi();
}
} catch (NumberFormatException | NullPointerException e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server send ack without sequence number");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": server send ack without sequence number");
}
} else if (nextTag.isStart("failed")) {
- Element failed = tagReader.readElement(nextTag);
- try {
- final int serverCount = Integer.parseInt(failed.getAttribute("h"));
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resumption failed but server acknowledged stanza #" + serverCount);
- final boolean acknowledgedMessages;
- synchronized (this.mStanzaQueue) {
- acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
- }
- if (acknowledgedMessages) {
- mXmppConnectionService.updateConversationUi();
- }
- } catch (NumberFormatException | NullPointerException e) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resumption failed");
- }
- resetStreamId();
- sendBindRequest();
+ final Element failed = tagReader.readElement(nextTag);
+ processFailed(failed, true);
} else if (nextTag.isStart("iq")) {
processIq(nextTag);
} else if (nextTag.isStart("message")) {
@@ -654,15 +663,382 @@ public class XmppConnection implements Runnable {
}
}
+ private void processChallenge(final Element challenge) throws IOException {
+ final SaslMechanism.Version version;
+ try {
+ version = SaslMechanism.Version.of(challenge);
+ } catch (final IllegalArgumentException e) {
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ final Element response;
+ if (version == SaslMechanism.Version.SASL) {
+ response = new Element("response", Namespace.SASL);
+ } else if (version == SaslMechanism.Version.SASL_2) {
+ response = new Element("response", Namespace.SASL_2);
+ } else {
+ throw new AssertionError("Missing implementation for " + version);
+ }
+ try {
+ response.setContent(saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket)));
+ } catch (final SaslMechanism.AuthenticationException e) {
+ // TODO: Send auth abort tag.
+ Log.e(Config.LOGTAG, e.toString());
+ throw new StateChangingException(Account.State.UNAUTHORIZED);
+ }
+ tagWriter.writeElement(response);
+ }
+
+ private boolean processSuccess(final Element success)
+ throws IOException, XmlPullParserException {
+ final SaslMechanism.Version version;
+ try {
+ version = SaslMechanism.Version.of(success);
+ } catch (final IllegalArgumentException e) {
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ final SaslMechanism currentSaslMechanism = this.saslMechanism;
+ if (currentSaslMechanism == null) {
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ final String challenge;
+ if (version == SaslMechanism.Version.SASL) {
+ challenge = success.getContent();
+ } else if (version == SaslMechanism.Version.SASL_2) {
+ challenge = success.findChildContent("additional-data");
+ } else {
+ throw new AssertionError("Missing implementation for " + version);
+ }
+ try {
+ currentSaslMechanism.getResponse(challenge, sslSocketOrNull(socket));
+ } catch (final SaslMechanism.AuthenticationException e) {
+ Log.e(Config.LOGTAG, String.valueOf(e));
+ throw new StateChangingException(Account.State.UNAUTHORIZED);
+ }
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid().toString() + ": logged in (using " + version + ")");
+ if (SaslMechanism.pin(currentSaslMechanism)) {
+ account.setPinnedMechanism(currentSaslMechanism);
+ }
+ if (version == SaslMechanism.Version.SASL_2) {
+ final String authorizationIdentifier =
+ success.findChildContent("authorization-identifier");
+ final Jid authorizationJid;
+ try {
+ authorizationJid =
+ Strings.isNullOrEmpty(authorizationIdentifier)
+ ? null
+ : Jid.ofEscaped(authorizationIdentifier);
+ } catch (final IllegalArgumentException e) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": SASL 2.0 authorization identifier was not a valid jid");
+ throw new StateChangingException(Account.State.BIND_FAILURE);
+ }
+ if (authorizationJid == null) {
+ throw new StateChangingException(Account.State.BIND_FAILURE);
+ }
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": SASL 2.0 authorization identifier was "
+ + authorizationJid);
+ if (!account.getJid().getDomain().equals(authorizationJid.getDomain())) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": server tried to re-assign domain to "
+ + authorizationJid.getDomain());
+ throw new StateChangingError(Account.State.BIND_FAILURE);
+ }
+ if (authorizationJid.isFullJid() && account.setJid(authorizationJid)) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": jid changed during SASL 2.0. updating database");
+ }
+ final boolean nopStreamFeatures;
+ final Element bound = success.findChild("bound", Namespace.BIND2);
+ final Element resumed = success.findChild("resumed", "urn:xmpp:sm:3");
+ final Element failed = success.findChild("failed", "urn:xmpp:sm:3");
+ final Element tokenWrapper = success.findChild("token", Namespace.FAST);
+ final String token = tokenWrapper == null ? null : tokenWrapper.getAttribute("token");
+ if (bound != null && resumed != null) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": server sent bound and resumed in SASL2 success");
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ final boolean processNopStreamFeatures;
+ if (resumed != null && streamId != null) {
+ processResumed(resumed);
+ } else if (failed != null) {
+ processFailed(failed, false); // wait for new stream features
+ }
+ if (bound != null) {
+ clearIqCallbacks();
+ this.isBound = true;
+ final Element streamManagementEnabled =
+ bound.findChild("enabled", Namespace.STREAM_MANAGEMENT);
+ final Element carbonsEnabled = bound.findChild("enabled", Namespace.CARBONS);
+ final boolean waitForDisco;
+ if (streamManagementEnabled != null) {
+ processEnabled(streamManagementEnabled);
+ waitForDisco = true;
+ } else {
+ //if we did not enable stream management in bind do it now
+ waitForDisco = enableStreamManagement();
+ }
+ if (carbonsEnabled != null) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid() + ": successfully enabled carbons");
+ features.carbonsEnabled = true;
+ }
+ sendPostBindInitialization(waitForDisco, carbonsEnabled != null);
+ processNopStreamFeatures = true;
+ } else {
+ processNopStreamFeatures = false;
+ }
+ final HashedToken.Mechanism tokenMechanism;
+ if (SaslMechanism.hashedToken(currentSaslMechanism)) {
+ tokenMechanism = ((HashedToken) currentSaslMechanism).getTokenMechanism();
+ } else if (this.hashTokenRequest != null) {
+ tokenMechanism = this.hashTokenRequest;
+ } else {
+ tokenMechanism = null;
+ }
+ if (tokenMechanism != null && !Strings.isNullOrEmpty(token)) {
+ this.account.setFastToken(tokenMechanism,token);
+ Log.d(Config.LOGTAG,account.getJid().asBareJid()+": storing hashed token "+tokenMechanism);
+ }
+ // a successful resume will not send stream features
+ if (processNopStreamFeatures) {
+ processNopStreamFeatures();
+ }
+ }
+ mXmppConnectionService.databaseBackend.updateAccount(account);
+ this.quickStartInProgress = false;
+ if (version == SaslMechanism.Version.SASL) {
+ tagReader.reset();
+ sendStartStream(false, true);
+ final Tag tag = tagReader.readTag();
+ if (tag != null && tag.isStart("stream", Namespace.STREAMS)) {
+ processStream();
+ return true;
+ } else {
+ throw new StateChangingException(Account.State.STREAM_OPENING_ERROR);
+ }
+ } else {
+ return false;
+ }
+ }
+
+ private void processNopStreamFeatures() throws IOException {
+ final Tag tag = tagReader.readTag();
+ if (tag != null && tag.isStart("features", Namespace.STREAMS)) {
+ this.streamFeatures = tagReader.readElement(tag);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": processed NOP stream features after success: "
+ + XmlHelper.printElementNames(this.streamFeatures));
+ } else {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received " + tag);
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": server did not send stream features after SASL2 success");
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ }
+
+ private void processFailure(final Element failure) throws IOException {
+ final SaslMechanism.Version version;
+ try {
+ version = SaslMechanism.Version.of(failure);
+ } catch (final IllegalArgumentException e) {
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ Log.d(Config.LOGTAG, failure.toString());
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": login failure " + version);
+ if (SaslMechanism.hashedToken(this.saslMechanism)) {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resetting token");
+ account.resetFastToken();
+ mXmppConnectionService.databaseBackend.updateAccount(account);
+ }
+ if (failure.hasChild("temporary-auth-failure")) {
+ throw new StateChangingException(Account.State.TEMPORARY_AUTH_FAILURE);
+ } else if (failure.hasChild("account-disabled")) {
+ final String text = failure.findChildContent("text");
+ if (Strings.isNullOrEmpty(text)) {
+ throw new StateChangingException(Account.State.UNAUTHORIZED);
+ }
+ final Matcher matcher = Patterns.AUTOLINK_WEB_URL.matcher(text);
+ if (matcher.find()) {
+ final HttpUrl url;
+ try {
+ url = HttpUrl.get(text.substring(matcher.start(), matcher.end()));
+ } catch (final IllegalArgumentException e) {
+ throw new StateChangingException(Account.State.UNAUTHORIZED);
+ }
+ if (url.isHttps()) {
+ this.redirectionUrl = url;
+ throw new StateChangingException(Account.State.PAYMENT_REQUIRED);
+ }
+ }
+ }
+ if (SaslMechanism.hashedToken(this.saslMechanism)) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": fast authentication failed. falling back to regular authentication");
+ authenticate();
+ } else {
+ throw new StateChangingException(Account.State.UNAUTHORIZED);
+ }
+ }
+
+ private static SSLSocket sslSocketOrNull(final Socket socket) {
+ if (socket instanceof SSLSocket) {
+ return (SSLSocket) socket;
+ } else {
+ return null;
+ }
+ }
+
+ private void processEnabled(final Element enabled) {
+ final String streamId;
+ if (enabled.getAttributeAsBoolean("resume")) {
+ streamId = enabled.getAttribute("id");
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid().toString()
+ + ": stream management enabled (resumable)");
+ } else {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid().toString() + ": stream management enabled");
+ streamId = null;
+ }
+ this.streamId = streamId;
+ this.stanzasReceived = 0;
+ this.inSmacksSession = true;
+ final RequestPacket r = new RequestPacket();
+ tagWriter.writeStanzaAsync(r);
+ }
+
+ private void processResumed(final Element resumed) throws StateChangingException {
+ this.inSmacksSession = true;
+ this.isBound = true;
+ this.tagWriter.writeStanzaAsync(new RequestPacket());
+ lastPacketReceived = SystemClock.elapsedRealtime();
+ final String h = resumed.getAttribute("h");
+ if (h == null) {
+ resetStreamId();
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ final int serverCount;
+ try {
+ serverCount = Integer.parseInt(h);
+ } catch (final NumberFormatException e) {
+ resetStreamId();
+ throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+ }
+ final ArrayList<AbstractAcknowledgeableStanza> failedStanzas = new ArrayList<>();
+ final boolean acknowledgedMessages;
+ synchronized (this.mStanzaQueue) {
+ if (serverCount < stanzasSent) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid() + ": session resumed with lost packages");
+ stanzasSent = serverCount;
+ } else {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": session resumed");
+ }
+ acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
+ for (int i = 0; i < this.mStanzaQueue.size(); ++i) {
+ failedStanzas.add(mStanzaQueue.valueAt(i));
+ }
+ mStanzaQueue.clear();
+ }
+ if (acknowledgedMessages) {
+ mXmppConnectionService.updateConversationUi();
+ }
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid() + ": resending " + failedStanzas.size() + " stanzas");
+ for (final AbstractAcknowledgeableStanza packet : failedStanzas) {
+ if (packet instanceof MessagePacket) {
+ MessagePacket message = (MessagePacket) packet;
+ mXmppConnectionService.markMessage(
+ account,
+ message.getTo().asBareJid(),
+ message.getId(),
+ Message.STATUS_UNSEND);
+ }
+ sendPacket(packet);
+ }
+ changeStatusToOnline();
+ }
+
+ private void changeStatusToOnline() {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid() + ": online with resource " + account.getResource());
+ changeStatus(Account.State.ONLINE);
+ }
+
+ private void processFailed(final Element failed, final boolean sendBindRequest) {
+ final int serverCount;
+ try {
+ serverCount = Integer.parseInt(failed.getAttribute("h"));
+ } catch (final NumberFormatException | NullPointerException e) {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resumption failed");
+ resetStreamId();
+ if (sendBindRequest) {
+ sendBindRequest();
+ }
+ return;
+ }
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": resumption failed but server acknowledged stanza #"
+ + serverCount);
+ final boolean acknowledgedMessages;
+ synchronized (this.mStanzaQueue) {
+ acknowledgedMessages = acknowledgeStanzaUpTo(serverCount);
+ }
+ if (acknowledgedMessages) {
+ mXmppConnectionService.updateConversationUi();
+ }
+ resetStreamId();
+ if (sendBindRequest) {
+ sendBindRequest();
+ }
+ }
+
private boolean acknowledgeStanzaUpTo(int serverCount) {
if (serverCount > stanzasSent) {
- Log.e(Config.LOGTAG, "server acknowledged more stanzas than we sent. serverCount=" + serverCount + ", ourCount=" + stanzasSent);
+ Log.e(
+ Config.LOGTAG,
+ "server acknowledged more stanzas than we sent. serverCount="
+ + serverCount
+ + ", ourCount="
+ + stanzasSent);
}
boolean acknowledgedMessages = false;
for (int i = 0; i < mStanzaQueue.size(); ++i) {
if (serverCount >= mStanzaQueue.keyAt(i)) {
if (Config.EXTENDED_SM_LOGGING) {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server acknowledged stanza #" + mStanzaQueue.keyAt(i));
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": server acknowledged stanza #"
+ + mStanzaQueue.keyAt(i));
}
final AbstractAcknowledgeableStanza stanza = mStanzaQueue.valueAt(i);
if (stanza instanceof MessagePacket && acknowledgedListener != null) {
@@ -0,0 +1,33 @@
+package eu.siacs.conversations.xmpp.bind;
+
+import com.google.common.collect.Collections2;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+
+public class Bind2 {
+
+ public static final Collection<String> QUICKSTART_FEATURES = Arrays.asList(
+ Namespace.CARBONS,
+ Namespace.STREAM_MANAGEMENT
+ );
+
+ public static Collection<String> features(final Element inline) {
+ final Element inlineBind2 =
+ inline != null ? inline.findChild("bind", Namespace.BIND2) : null;
+ final Element inlineBind2Inline =
+ inlineBind2 != null ? inlineBind2.findChild("inline", Namespace.BIND2) : null;
+ if (inlineBind2 == null) {
+ return null;
+ }
+ if (inlineBind2Inline == null) {
+ return Collections.emptyList();
+ }
+ return Collections2.transform(
+ inlineBind2Inline.getChildren(), c -> c == null ? null : c.getAttribute("var"));
+ }
+}
@@ -0,0 +1,88 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Set;
+
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
+
+public final class ContentAddition {
+
+ public final Direction direction;
+ public final Set<Summary> summary;
+
+ private ContentAddition(Direction direction, Set<Summary> summary) {
+ this.direction = direction;
+ this.summary = summary;
+ }
+
+ public Set<Media> media() {
+ return ImmutableSet.copyOf(Collections2.transform(summary, s -> s.media));
+ }
+
+ public static ContentAddition of(final Direction direction, final RtpContentMap rtpContentMap) {
+ return new ContentAddition(direction, summary(rtpContentMap));
+ }
+
+ public static Set<Summary> summary(final RtpContentMap rtpContentMap) {
+ return ImmutableSet.copyOf(
+ Collections2.transform(
+ rtpContentMap.contents.entrySet(),
+ e -> {
+ final RtpContentMap.DescriptionTransport dt = e.getValue();
+ return new Summary(e.getKey(), dt.description.getMedia(), dt.senders);
+ }));
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("direction", direction)
+ .add("summary", summary)
+ .toString();
+ }
+
+ public enum Direction {
+ OUTGOING,
+ INCOMING
+ }
+
+ public static final class Summary {
+ public final String name;
+ public final Media media;
+ public final Content.Senders senders;
+
+ private Summary(final String name, final Media media, final Content.Senders senders) {
+ this.name = name;
+ this.media = media;
+ this.senders = senders;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Summary summary = (Summary) o;
+ return Objects.equal(name, summary.name)
+ && media == summary.media
+ && senders == summary.senders;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(name, media, senders);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("name", name)
+ .add("media", media)
+ .add("senders", senders)
+ .toString();
+ }
+ }
+}
@@ -594,8 +594,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendInitRequest() {
final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INITIATE);
- final Content content = new Content(this.contentCreator, this.contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(this.contentCreator, this.contentSenders, 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);
@@ -672,8 +671,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
gatherAndConnectDirectCandidates();
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.setSenders(this.contentSenders);
+ final Content content = new Content(contentCreator, contentSenders, contentName);
content.setDescription(this.description);
if (success && candidate != null && !equalCandidateExists(candidate)) {
final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate);
@@ -712,8 +710,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendAcceptIbb() {
this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize);
final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT);
- final Content content = new Content(contentCreator, contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(contentCreator, contentSenders, contentName);
content.setDescription(this.description);
content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
packet.addJingleContent(content);
@@ -926,8 +923,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendFallbackToIbb() {
Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending fallback to ibb");
final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.TRANSPORT_REPLACE);
- final Content content = new Content(this.contentCreator, this.contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
this.transportId = JingleConnectionManager.nextRandomId();
content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
packet.addJingleContent(content);
@@ -960,8 +956,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
final JinglePacket answer = bootstrapPacket(JinglePacket.Action.TRANSPORT_ACCEPT);
- final Content content = new Content(contentCreator, contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(contentCreator, contentSenders, contentName);
content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize));
answer.addJingleContent(content);
@@ -1140,8 +1135,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendProxyActivated(String cid) {
final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
- final Content content = new Content(this.contentCreator, this.contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
content.setTransport(new S5BTransportInfo(this.transportId, new Element("activated").setAttribute("cid", cid)));
packet.addJingleContent(content);
this.sendJinglePacket(packet);
@@ -1149,8 +1143,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendProxyError() {
final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
- final Content content = new Content(this.contentCreator, this.contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
content.setTransport(new S5BTransportInfo(this.transportId, new Element("proxy-error")));
packet.addJingleContent(content);
this.sendJinglePacket(packet);
@@ -1158,8 +1151,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendCandidateUsed(final String cid) {
JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO);
- final Content content = new Content(this.contentCreator, this.contentName);
- content.setSenders(this.contentSenders);
+ final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-used").setAttribute("cid", cid)));
packet.addJingleContent(content);
this.sentCandidate = true;
@@ -1172,8 +1164,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple
private void sendCandidateError() {
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.setSenders(this.contentSenders);
+ Content content = new Content(this.contentCreator, this.contentSenders, this.contentName);
content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-error")));
packet.addJingleContent(content);
this.sentCandidate = true;
@@ -6,6 +6,7 @@ import android.os.Environment;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Stopwatch;
@@ -14,6 +15,7 @@ import com.google.common.base.Throwables;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.primitives.Ints;
import com.google.common.util.concurrent.FutureCallback;
@@ -41,6 +43,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
+import eu.siacs.conversations.BuildConfig;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.crypto.axolotl.AxolotlService;
import eu.siacs.conversations.crypto.axolotl.CryptoFailedException;
@@ -55,6 +58,7 @@ import eu.siacs.conversations.utils.IP;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
@@ -165,6 +169,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
private Set<Media> proposedMedia;
private RtpContentMap initiatorRtpContentMap;
private RtpContentMap responderRtpContentMap;
+ private RtpContentMap incomingContentAdd;
+ private RtpContentMap outgoingContentAdd;
private IceUdpTransportInfo.Setup peerDtlsSetup;
private final Stopwatch sessionDuration = Stopwatch.createUnstarted();
private final Queue<PeerConnection.PeerConnectionState> stateHistory = new LinkedList<>();
@@ -221,6 +227,18 @@ public class JingleRtpConnection extends AbstractJingleConnection
case SESSION_TERMINATE:
receiveSessionTerminate(jinglePacket);
break;
+ case CONTENT_ADD:
+ receiveContentAdd(jinglePacket);
+ break;
+ case CONTENT_ACCEPT:
+ receiveContentAccept(jinglePacket);
+ break;
+ case CONTENT_REJECT:
+ receiveContentReject(jinglePacket);
+ break;
+ case CONTENT_REMOVE:
+ receiveContentRemove(jinglePacket);
+ break;
default:
respondOk(jinglePacket);
Log.d(
@@ -353,6 +371,405 @@ public class JingleRtpConnection extends AbstractJingleConnection
}
}
+ private void receiveContentAdd(final JinglePacket jinglePacket) {
+ final RtpContentMap modification;
+ try {
+ modification = RtpContentMap.of(jinglePacket);
+ modification.requireContentDescriptions();
+ } catch (final RuntimeException e) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+ Throwables.getRootCause(e));
+ respondOk(jinglePacket);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.of(e), e.getMessage());
+ return;
+ }
+ if (isInState(State.SESSION_ACCEPTED)) {
+ receiveContentAdd(jinglePacket, modification);
+ } else {
+ terminateWithOutOfOrder(jinglePacket);
+ }
+ }
+
+ private void receiveContentAdd(
+ final JinglePacket jinglePacket, final RtpContentMap modification) {
+ final RtpContentMap remote = getRemoteContentMap();
+ if (!Collections.disjoint(modification.getNames(), remote.getNames())) {
+ respondOk(jinglePacket);
+ this.webRTCWrapper.close();
+ sendSessionTerminate(
+ Reason.FAILED_APPLICATION,
+ String.format(
+ "contents with names %s already exists",
+ Joiner.on(", ").join(modification.getNames())));
+ return;
+ }
+ final ContentAddition contentAddition =
+ ContentAddition.of(ContentAddition.Direction.INCOMING, modification);
+
+ final RtpContentMap outgoing = this.outgoingContentAdd;
+ final Set<ContentAddition.Summary> outgoingContentAddSummary =
+ outgoing == null ? Collections.emptySet() : ContentAddition.summary(outgoing);
+
+ if (outgoingContentAddSummary.equals(contentAddition.summary)) {
+ if (isInitiator()) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": respond with tie break to matching content-add offer");
+ respondWithTieBreak(jinglePacket);
+ } else {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": automatically accept matching content-add offer");
+ acceptContentAdd(contentAddition.summary, modification);
+ }
+ return;
+ }
+
+ // once we can display multiple video tracks we can be more loose with this condition
+ // theoretically it should also be fine to automatically accept audio only contents
+ if (Media.audioOnly(remote.getMedia()) && Media.videoOnly(contentAddition.media())) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid() + ": received " + contentAddition);
+ this.incomingContentAdd = modification;
+ respondOk(jinglePacket);
+ updateEndUserState();
+ } else {
+ respondOk(jinglePacket);
+ // TODO do we want to add a reason?
+ rejectContentAdd(modification);
+ }
+ }
+
+ private void receiveContentAccept(final JinglePacket jinglePacket) {
+ final RtpContentMap receivedContentAccept;
+ try {
+ receivedContentAccept = RtpContentMap.of(jinglePacket);
+ receivedContentAccept.requireContentDescriptions();
+ } catch (final RuntimeException e) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+ Throwables.getRootCause(e));
+ respondOk(jinglePacket);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.of(e), e.getMessage());
+ return;
+ }
+
+ final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
+ if (outgoingContentAdd == null) {
+ Log.d(Config.LOGTAG, "received content-accept when we had no outgoing content add");
+ terminateWithOutOfOrder(jinglePacket);
+ return;
+ }
+ final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
+ if (ourSummary.equals(ContentAddition.summary(receivedContentAccept))) {
+ this.outgoingContentAdd = null;
+ respondOk(jinglePacket);
+ receiveContentAccept(receivedContentAccept);
+ } else {
+ Log.d(Config.LOGTAG, "received content-accept did not match our outgoing content-add");
+ terminateWithOutOfOrder(jinglePacket);
+ }
+ }
+
+ private void receiveContentAccept(final RtpContentMap receivedContentAccept) {
+ final IceUdpTransportInfo.Setup peerDtlsSetup = getPeerDtlsSetup();
+ final RtpContentMap modifiedContentMap =
+ getRemoteContentMap().addContent(receivedContentAccept, peerDtlsSetup);
+
+ setRemoteContentMap(modifiedContentMap);
+
+ final SessionDescription answer = SessionDescription.of(modifiedContentMap, !isInitiator());
+
+ final org.webrtc.SessionDescription sdp =
+ new org.webrtc.SessionDescription(
+ org.webrtc.SessionDescription.Type.ANSWER, answer.toString());
+
+ try {
+ this.webRTCWrapper.setRemoteDescription(sdp).get();
+ } catch (final Exception e) {
+ final Throwable cause = Throwables.getRootCause(e);
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": unable to set remote description after receiving content-accept",
+ cause);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
+ return;
+ }
+ updateEndUserState();
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": remote has accepted content-add "
+ + ContentAddition.summary(receivedContentAccept));
+ }
+
+ private void receiveContentReject(final JinglePacket jinglePacket) {
+ final RtpContentMap receivedContentReject;
+ try {
+ receivedContentReject = RtpContentMap.of(jinglePacket);
+ } catch (final RuntimeException e) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+ Throwables.getRootCause(e));
+ respondOk(jinglePacket);
+ this.webRTCWrapper.close();
+ sendSessionTerminate(Reason.of(e), e.getMessage());
+ return;
+ }
+
+ final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
+ if (outgoingContentAdd == null) {
+ Log.d(Config.LOGTAG, "received content-reject when we had no outgoing content add");
+ terminateWithOutOfOrder(jinglePacket);
+ return;
+ }
+ final Set<ContentAddition.Summary> ourSummary = ContentAddition.summary(outgoingContentAdd);
+ if (ourSummary.equals(ContentAddition.summary(receivedContentReject))) {
+ this.outgoingContentAdd = null;
+ respondOk(jinglePacket);
+ Log.d(Config.LOGTAG,jinglePacket.toString());
+ receiveContentReject(ourSummary);
+ } else {
+ Log.d(Config.LOGTAG, "received content-reject did not match our outgoing content-add");
+ terminateWithOutOfOrder(jinglePacket);
+ }
+ }
+
+ private void receiveContentReject(final Set<ContentAddition.Summary> summary) {
+ try {
+ this.webRTCWrapper.removeTrack(Media.VIDEO);
+ final RtpContentMap localContentMap = customRollback();
+ modifyLocalContentMap(localContentMap);
+ } catch (final Exception e) {
+ final Throwable cause = Throwables.getRootCause(e);
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": unable to rollback local description after receiving content-reject",
+ cause);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
+ return;
+ }
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": remote has rejected our content-add "
+ + summary);
+ }
+
+ private void receiveContentRemove(final JinglePacket jinglePacket) {
+ final RtpContentMap receivedContentRemove;
+ try {
+ receivedContentRemove = RtpContentMap.of(jinglePacket);
+ receivedContentRemove.requireContentDescriptions();
+ } catch (final RuntimeException e) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid() + ": improperly formatted contents",
+ Throwables.getRootCause(e));
+ respondOk(jinglePacket);
+ this.webRTCWrapper.close();
+ sendSessionTerminate(Reason.of(e), e.getMessage());
+ return;
+ }
+ respondOk(jinglePacket);
+ receiveContentRemove(receivedContentRemove);
+ }
+
+ private void receiveContentRemove(final RtpContentMap receivedContentRemove) {
+ final RtpContentMap incomingContentAdd = this.incomingContentAdd;
+ final Set<ContentAddition.Summary> contentAddSummary =
+ incomingContentAdd == null
+ ? Collections.emptySet()
+ : ContentAddition.summary(incomingContentAdd);
+ final Set<ContentAddition.Summary> removeSummary =
+ ContentAddition.summary(receivedContentRemove);
+ if (contentAddSummary.equals(removeSummary)) {
+ this.incomingContentAdd = null;
+ updateEndUserState();
+ } else {
+ webRTCWrapper.close();
+ sendSessionTerminate(
+ Reason.FAILED_APPLICATION,
+ String.format(
+ "%s only supports %s as a means to retract a not yet accepted %s",
+ BuildConfig.APP_NAME,
+ JinglePacket.Action.CONTENT_REMOVE,
+ JinglePacket.Action.CONTENT_ACCEPT));
+ }
+ }
+
+ public synchronized void retractContentAdd() {
+ final RtpContentMap outgoingContentAdd = this.outgoingContentAdd;
+ if (outgoingContentAdd == null) {
+ throw new IllegalStateException("Not outgoing content add");
+ }
+ try {
+ webRTCWrapper.removeTrack(Media.VIDEO);
+ final RtpContentMap localContentMap = customRollback();
+ modifyLocalContentMap(localContentMap);
+ } catch (final Exception e) {
+ final Throwable cause = Throwables.getRootCause(e);
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": unable to rollback local description after trying to retract content-add",
+ cause);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
+ return;
+ }
+ this.outgoingContentAdd = null;
+ final JinglePacket retract =
+ outgoingContentAdd
+ .toStub()
+ .toJinglePacket(JinglePacket.Action.CONTENT_REMOVE, id.sessionId);
+ this.send(retract);
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid()
+ + ": retract content-add "
+ + ContentAddition.summary(outgoingContentAdd));
+ }
+
+ private RtpContentMap customRollback() throws ExecutionException, InterruptedException {
+ final SessionDescription sdp = setLocalSessionDescription();
+ final RtpContentMap localRtpContentMap = RtpContentMap.of(sdp, isInitiator());
+ final SessionDescription answer = generateFakeResponse(localRtpContentMap);
+ this.webRTCWrapper
+ .setRemoteDescription(
+ new org.webrtc.SessionDescription(
+ org.webrtc.SessionDescription.Type.ANSWER, answer.toString()))
+ .get();
+ return localRtpContentMap;
+ }
+
+ private SessionDescription generateFakeResponse(final RtpContentMap localContentMap) {
+ final RtpContentMap currentRemote = getRemoteContentMap();
+ final RtpContentMap.Diff diff = currentRemote.diff(localContentMap);
+ if (diff.isEmpty()) {
+ throw new IllegalStateException(
+ "Unexpected rollback condition. No difference between local and remote");
+ }
+ final RtpContentMap patch = localContentMap.toContentModification(diff.added);
+ if (ImmutableSet.of(Content.Senders.NONE).equals(patch.getSenders())) {
+ final RtpContentMap nextRemote =
+ currentRemote.addContent(
+ patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup());
+ return SessionDescription.of(nextRemote, !isInitiator());
+ }
+ throw new IllegalStateException(
+ "Unexpected rollback condition. Senders were not uniformly none");
+ }
+
+ public synchronized void acceptContentAdd(@NonNull final Set<ContentAddition.Summary> contentAddition) {
+ final RtpContentMap incomingContentAdd = this.incomingContentAdd;
+ if (incomingContentAdd == null) {
+ throw new IllegalStateException("No incoming content add");
+ }
+
+ if (contentAddition.equals(ContentAddition.summary(incomingContentAdd))) {
+ this.incomingContentAdd = null;
+ acceptContentAdd(contentAddition, incomingContentAdd);
+ } else {
+ throw new IllegalStateException("Accepted content add does not match pending content-add");
+ }
+ }
+
+ private void acceptContentAdd(@NonNull final Set<ContentAddition.Summary> contentAddition, final RtpContentMap incomingContentAdd) {
+ final IceUdpTransportInfo.Setup setup = getPeerDtlsSetup();
+ final RtpContentMap modifiedContentMap = getRemoteContentMap().addContent(incomingContentAdd, setup);
+ this.setRemoteContentMap(modifiedContentMap);
+
+ final SessionDescription offer;
+ try {
+ offer = SessionDescription.of(modifiedContentMap, !isInitiator());
+ } catch (final IllegalArgumentException | NullPointerException e) {
+ Log.d(Config.LOGTAG, id.getAccount().getJid().asBareJid() + ": unable convert offer from content-add to SDP", e);
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
+ return;
+ }
+ this.incomingContentAdd = null;
+ acceptContentAdd(contentAddition, offer);
+ }
+
+ private void acceptContentAdd(
+ final Set<ContentAddition.Summary> contentAddition, final SessionDescription offer) {
+ final org.webrtc.SessionDescription sdp =
+ new org.webrtc.SessionDescription(
+ org.webrtc.SessionDescription.Type.OFFER, offer.toString());
+ try {
+ this.webRTCWrapper.setRemoteDescription(sdp).get();
+
+ // TODO add tracks for 'media' where contentAddition.senders matches
+
+ // TODO if senders.sending(isInitiator())
+
+ this.webRTCWrapper.addTrack(Media.VIDEO);
+
+ // TODO add additional transceivers for recv only cases
+
+ final SessionDescription answer = setLocalSessionDescription();
+ final RtpContentMap rtpContentMap = RtpContentMap.of(answer, isInitiator());
+
+ final RtpContentMap contentAcceptMap =
+ rtpContentMap.toContentModification(
+ Collections2.transform(contentAddition, ca -> ca.name));
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": sending content-accept "
+ + ContentAddition.summary(contentAcceptMap));
+ modifyLocalContentMap(rtpContentMap);
+ sendContentAccept(contentAcceptMap);
+ } catch (final Exception e) {
+ Log.d(Config.LOGTAG, "unable to accept content add", Throwables.getRootCause(e));
+ webRTCWrapper.close();
+ sendSessionTerminate(Reason.FAILED_APPLICATION);
+ }
+ }
+
+ private void sendContentAccept(final RtpContentMap contentAcceptMap) {
+ final JinglePacket jinglePacket = contentAcceptMap.toJinglePacket(JinglePacket.Action.CONTENT_ACCEPT, id.sessionId);
+ send(jinglePacket);
+ }
+
+ public synchronized void rejectContentAdd() {
+ final RtpContentMap incomingContentAdd = this.incomingContentAdd;
+ if (incomingContentAdd == null) {
+ throw new IllegalStateException("No incoming content add");
+ }
+ this.incomingContentAdd = null;
+ updateEndUserState();
+ rejectContentAdd(incomingContentAdd);
+ }
+
+ private void rejectContentAdd(final RtpContentMap contentMap) {
+ final JinglePacket jinglePacket =
+ contentMap
+ .toStub()
+ .toJinglePacket(JinglePacket.Action.CONTENT_REJECT, id.sessionId);
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": rejecting content "
+ + ContentAddition.summary(contentMap));
+ send(jinglePacket);
+ }
+
private boolean checkForIceRestart(
final JinglePacket jinglePacket, final RtpContentMap rtpContentMap) {
final RtpContentMap existing = getRemoteContentMap();
@@ -434,7 +851,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
final RtpContentMap restartContentMap,
final boolean isOffer)
throws ExecutionException, InterruptedException {
- final SessionDescription sessionDescription = SessionDescription.of(restartContentMap);
+ final SessionDescription sessionDescription = SessionDescription.of(restartContentMap, !isInitiator());
final org.webrtc.SessionDescription.Type type =
isOffer
? org.webrtc.SessionDescription.Type.OFFER
@@ -453,7 +870,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
if (isOffer) {
webRTCWrapper.setIsReadyToReceiveIceCandidates(false);
final SessionDescription localSessionDescription = setLocalSessionDescription();
- setLocalContentMap(RtpContentMap.of(localSessionDescription));
+ setLocalContentMap(RtpContentMap.of(localSessionDescription, isInitiator()));
// We need to respond OK before sending any candidates
respondOk(jinglePacket);
webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
@@ -508,6 +925,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
return isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
}
+ private RtpContentMap getLocalContentMap() {
+ return isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
+ }
+
private List<String> toIdentificationTags(final RtpContentMap rtpContentMap) {
final Group originalGroup = rtpContentMap.group;
final List<String> identificationTags =
@@ -735,7 +1156,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
this.storePeerDtlsSetup(contentMap.getDtlsSetup());
final SessionDescription sessionDescription;
try {
- sessionDescription = SessionDescription.of(contentMap);
+ sessionDescription = SessionDescription.of(contentMap, false);
} catch (final IllegalArgumentException | NullPointerException e) {
Log.d(
Config.LOGTAG,
@@ -772,7 +1193,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
}
final SessionDescription offer;
try {
- offer = SessionDescription.of(rtpContentMap);
+ offer = SessionDescription.of(rtpContentMap, true);
} catch (final IllegalArgumentException | NullPointerException e) {
Log.d(
Config.LOGTAG,
@@ -847,10 +1268,9 @@ public class JingleRtpConnection extends AbstractJingleConnection
final org.webrtc.SessionDescription webRTCSessionDescription) {
final SessionDescription sessionDescription =
SessionDescription.parse(webRTCSessionDescription.description);
- final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
+ final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription, false);
this.responderRtpContentMap = respondingRtpContentMap;
storePeerDtlsSetup(respondingRtpContentMap.getDtlsSetup().flip());
- webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
prepareOutgoingContentMap(respondingRtpContentMap);
Futures.addCallback(
@@ -859,6 +1279,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
@Override
public void onSuccess(final RtpContentMap outgoingContentMap) {
sendSessionAccept(outgoingContentMap);
+ webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
}
@Override
@@ -955,16 +1376,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
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();
+ acceptedOnOtherDevice(serverMsgId, timestamp);
} else {
Log.d(
Config.LOGTAG,
@@ -979,6 +1391,19 @@ public class JingleRtpConnection extends AbstractJingleConnection
}
}
+ private void acceptedOnOtherDevice(final String serverMsgId, final long timestamp) {
+ 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();
+ }
+
private void receiveReject(final Jid from, final String serverMsgId, final long timestamp) {
final boolean originatedFromMyself =
from.asBareJid().equals(id.account.getJid().asBareJid());
@@ -1176,11 +1601,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
id.account.getJid().asBareJid()
+ ": moved session with "
+ id.with
- + " into state accepted after received carbon copied procced");
- this.xmppConnectionService
- .getNotificationService()
- .cancelIncomingCallNotification();
- this.finish();
+ + " into state accepted after received carbon copied proceed");
+ acceptedOnOtherDevice(serverMsgId, timestamp);
}
} else {
Log.d(
@@ -1298,9 +1720,8 @@ public class JingleRtpConnection extends AbstractJingleConnection
final org.webrtc.SessionDescription webRTCSessionDescription, final State targetState) {
final SessionDescription sessionDescription =
SessionDescription.parse(webRTCSessionDescription.description);
- final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
+ final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, true);
this.initiatorRtpContentMap = rtpContentMap;
- this.webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
final ListenableFuture<RtpContentMap> outgoingContentMapFuture =
encryptSessionInitiate(rtpContentMap);
Futures.addCallback(
@@ -1309,6 +1730,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
@Override
public void onSuccess(final RtpContentMap outgoingContentMap) {
sendSessionInitiate(outgoingContentMap, targetState);
+ webRTCWrapper.setIsReadyToReceiveIceCandidates(true);
}
@Override
@@ -1535,6 +1957,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
return RtpEndUserState.CONNECTING;
}
case SESSION_ACCEPTED:
+ final ContentAddition ca = getPendingContentAddition();
+ if (ca != null && ca.direction == ContentAddition.Direction.INCOMING) {
+ return RtpEndUserState.INCOMING_CONTENT_ADD;
+ }
return getPeerConnectionStateAsEndUserState();
case REJECTED:
case REJECTED_RACED:
@@ -1592,6 +2018,18 @@ public class JingleRtpConnection extends AbstractJingleConnection
}
}
+ public ContentAddition getPendingContentAddition() {
+ final RtpContentMap in = this.incomingContentAdd;
+ final RtpContentMap out = this.outgoingContentAdd;
+ if (out != null) {
+ return ContentAddition.of(ContentAddition.Direction.OUTGOING, out);
+ } else if (in != null) {
+ return ContentAddition.of(ContentAddition.Direction.INCOMING, in);
+ } else {
+ return null;
+ }
+ }
+
public Set<Media> getMedia() {
final State current = getState();
if (current == State.NULL) {
@@ -1605,14 +2043,16 @@ public class JingleRtpConnection extends AbstractJingleConnection
return Preconditions.checkNotNull(
this.proposedMedia, "RTP connection has not been initialized properly");
}
+ final RtpContentMap localContentMap = getLocalContentMap();
final RtpContentMap initiatorContentMap = initiatorRtpContentMap;
- if (initiatorContentMap != null) {
+ if (localContentMap != null) {
+ return localContentMap.getMedia();
+ } else if (initiatorContentMap != null) {
return initiatorContentMap.getMedia();
} else if (isTerminated()) {
- return Collections.emptySet(); // we might fail before we ever got a chance to set media
+ return Collections.emptySet(); //we might fail before we ever got a chance to set media
} else {
- return Preconditions.checkNotNull(
- this.proposedMedia, "RTP connection has not been initialized properly");
+ return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly");
}
}
@@ -1626,6 +2066,16 @@ public class JingleRtpConnection extends AbstractJingleConnection
return status != null && status.isVerified();
}
+ public boolean addMedia(final Media media) {
+ final Set<Media> currentMedia = getMedia();
+ if (currentMedia.contains(media)) {
+ throw new IllegalStateException(String.format("%s has already been proposed", media));
+ }
+ // TODO add state protection - can only add while ACCEPTED or so
+ Log.d(Config.LOGTAG,"adding media: "+media);
+ return webRTCWrapper.addTrack(media);
+ }
+
public synchronized void acceptCall() {
switch (this.state) {
case PROPOSED:
@@ -1744,17 +2194,9 @@ public class JingleRtpConnection extends AbstractJingleConnection
finish();
}
- private void setupWebRTC(
- final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
- throws WebRTCWrapper.InitializationException {
+ private void setupWebRTC(final Set<Media> media, final List<PeerConnection.IceServer> 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.setup(this.xmppConnectionService, AppRTCAudioManager.SpeakerPhonePreference.of(media));
this.webRTCWrapper.initializePeerConnection(media, iceServers);
}
@@ -1906,31 +2348,71 @@ public class JingleRtpConnection extends AbstractJingleConnection
webRTCWrapper.execute(this::closeWebRTCSessionAfterFailedConnection);
return;
} else {
- webRTCWrapper.restartIce();
+ this.restartIce();
}
}
updateEndUserState();
}
+ private void restartIce() {
+ this.stateHistory.clear();
+ this.webRTCWrapper.restartIce();
+ }
+
@Override
public void onRenegotiationNeeded() {
- this.webRTCWrapper.execute(this::initiateIceRestart);
+ this.webRTCWrapper.execute(this::renegotiate);
}
- private void initiateIceRestart() {
- // TODO discover new TURN/STUN credentials
- this.stateHistory.clear();
- this.webRTCWrapper.setIsReadyToReceiveIceCandidates(false);
+ private void renegotiate() {
final SessionDescription sessionDescription;
try {
sessionDescription = setLocalSessionDescription();
} catch (final Exception e) {
final Throwable cause = Throwables.getRootCause(e);
Log.d(Config.LOGTAG, "failed to renegotiate", cause);
+ webRTCWrapper.close();
sendSessionTerminate(Reason.FAILED_APPLICATION, cause.getMessage());
return;
}
- final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
+ final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription, isInitiator());
+ final RtpContentMap currentContentMap = getLocalContentMap();
+ final boolean iceRestart = currentContentMap.iceRestart(rtpContentMap);
+ final RtpContentMap.Diff diff = currentContentMap.diff(rtpContentMap);
+
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": renegotiate. iceRestart="
+ + iceRestart
+ + " content id diff="
+ + diff);
+
+ if (diff.hasModifications() && iceRestart) {
+ webRTCWrapper.close();
+ sendSessionTerminate(
+ Reason.FAILED_APPLICATION,
+ "WebRTC unexpectedly tried to modify content and transport at once");
+ return;
+ }
+
+ if (iceRestart) {
+ initiateIceRestart(rtpContentMap);
+ return;
+ } else if (diff.isEmpty()) {
+ Log.d(
+ Config.LOGTAG,
+ "renegotiation. nothing to do. SignalingState="
+ + this.webRTCWrapper.getSignalingState());
+ }
+
+ if (diff.added.size() > 0) {
+ modifyLocalContentMap(rtpContentMap);
+ sendContentAdd(rtpContentMap, diff.added);
+ }
+ }
+
+ private void initiateIceRestart(final RtpContentMap rtpContentMap) {
final RtpContentMap transportInfo = rtpContentMap.transportInfo();
final JinglePacket jinglePacket =
transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
@@ -1947,8 +2429,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
return;
}
if (response.getType() == IqPacket.TYPE.ERROR) {
- final Element error = response.findChild("error");
- if (error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS)) {
+ if (isTieBreak(response)) {
Log.d(Config.LOGTAG, "received tie-break as result of ice restart");
return;
}
@@ -1960,6 +2441,42 @@ public class JingleRtpConnection extends AbstractJingleConnection
});
}
+ private boolean isTieBreak(final IqPacket response) {
+ final Element error = response.findChild("error");
+ return error != null && error.hasChild("tie-break", Namespace.JINGLE_ERRORS);
+ }
+
+ private void sendContentAdd(final RtpContentMap rtpContentMap, final Collection<String> added) {
+ final RtpContentMap contentAdd = rtpContentMap.toContentModification(added);
+ this.outgoingContentAdd = contentAdd;
+ final JinglePacket jinglePacket =
+ contentAdd.toJinglePacket(JinglePacket.Action.CONTENT_ADD, id.sessionId);
+ jinglePacket.setTo(id.with);
+ xmppConnectionService.sendIqPacket(
+ id.account,
+ jinglePacket,
+ (connection, response) -> {
+ if (response.getType() == IqPacket.TYPE.RESULT) {
+ Log.d(
+ Config.LOGTAG,
+ id.getAccount().getJid().asBareJid()
+ + ": received ACK to our content-add");
+ return;
+ }
+ if (response.getType() == IqPacket.TYPE.ERROR) {
+ if (isTieBreak(response)) {
+ this.outgoingContentAdd = null;
+ Log.d(Config.LOGTAG, "received tie-break as result of our content-add");
+ return;
+ }
+ handleIqErrorResponse(response);
+ }
+ if (response.getType() == IqPacket.TYPE.TIMEOUT) {
+ handleIqTimeoutResponse(response);
+ }
+ });
+ }
+
private void setLocalContentMap(final RtpContentMap rtpContentMap) {
if (isInitiator()) {
this.initiatorRtpContentMap = rtpContentMap;
@@ -1976,6 +2493,15 @@ public class JingleRtpConnection extends AbstractJingleConnection
}
}
+ // this method is to be used for content map modifications that modify media
+ private void modifyLocalContentMap(final RtpContentMap rtpContentMap) {
+ final RtpContentMap activeContents = rtpContentMap.activeContents();
+ setLocalContentMap(activeContents);
+ this.webRTCWrapper.switchSpeakerPhonePreference(
+ AppRTCAudioManager.SpeakerPhonePreference.of(activeContents.getMedia()));
+ updateEndUserState();
+ }
+
private SessionDescription setLocalSessionDescription()
throws ExecutionException, InterruptedException {
final org.webrtc.SessionDescription sessionDescription =
@@ -1,11 +1,18 @@
package eu.siacs.conversations.xmpp.jingle;
+import com.google.common.collect.ImmutableSet;
+
import java.util.Locale;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
public enum Media {
+
VIDEO, AUDIO, UNKNOWN;
@Override
+ @Nonnull
public String toString() {
return super.toString().toLowerCase(Locale.ROOT);
}
@@ -17,4 +24,12 @@ public enum Media {
return UNKNOWN;
}
}
+
+ public static boolean audioOnly(Set<Media> media) {
+ return ImmutableSet.of(AUDIO).equals(media);
+ }
+
+ public static boolean videoOnly(Set<Media> media) {
+ return ImmutableSet.of(VIDEO).equals(media);
+ }
}
@@ -1,6 +1,9 @@
package eu.siacs.conversations.xmpp.jingle;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
+import com.google.common.base.Predicates;
import com.google.common.base.Strings;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
@@ -11,10 +14,13 @@ import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import javax.annotation.Nonnull;
+
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
@@ -58,13 +64,15 @@ public class RtpContentMap {
return true;
}
- public static RtpContentMap of(final SessionDescription sessionDescription) {
+ public static RtpContentMap of(
+ final SessionDescription sessionDescription, final boolean isInitiator) {
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
new ImmutableMap.Builder<>();
for (SessionDescription.Media media : sessionDescription.media) {
final String id = Iterables.getFirst(media.attributes.get("mid"), null);
Preconditions.checkNotNull(id, "media has no mid");
- contentMapBuilder.put(id, DescriptionTransport.of(sessionDescription, media));
+ contentMapBuilder.put(
+ id, DescriptionTransport.of(sessionDescription, isInitiator, media));
}
final String groupAttribute =
Iterables.getFirst(sessionDescription.attributes.get("group"), null);
@@ -85,6 +93,10 @@ public class RtpContentMap {
}));
}
+ public Set<Content.Senders> getSenders() {
+ return ImmutableSet.copyOf(Collections2.transform(contents.values(),dt -> dt.senders));
+ }
+
public List<String> getNames() {
return ImmutableList.copyOf(contents.keySet());
}
@@ -140,11 +152,16 @@ public class RtpContentMap {
jinglePacket.addGroup(this.group);
}
for (Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
- final Content content = new Content(Content.Creator.INITIATOR, entry.getKey());
- if (entry.getValue().description != null) {
- content.addChild(entry.getValue().description);
+ final DescriptionTransport descriptionTransport = entry.getValue();
+ final Content content =
+ new Content(
+ Content.Creator.INITIATOR,
+ descriptionTransport.senders,
+ entry.getKey());
+ if (descriptionTransport.description != null) {
+ content.addChild(descriptionTransport.description);
}
- content.addChild(entry.getValue().transport);
+ content.addChild(descriptionTransport.transport);
jinglePacket.addJingleContent(content);
}
return jinglePacket;
@@ -163,7 +180,10 @@ public class RtpContentMap {
newTransportInfo.addChild(candidate);
return new RtpContentMap(
null,
- ImmutableMap.of(contentName, new DescriptionTransport(null, newTransportInfo)));
+ ImmutableMap.of(
+ contentName,
+ new DescriptionTransport(
+ descriptionTransport.senders, null, newTransportInfo)));
}
RtpContentMap transportInfo() {
@@ -171,7 +191,9 @@ public class RtpContentMap {
null,
Maps.transformValues(
contents,
- dt -> new DescriptionTransport(null, dt.transport.cloneWrapper())));
+ dt ->
+ new DescriptionTransport(
+ dt.senders, null, dt.transport.cloneWrapper())));
}
public IceUdpTransportInfo.Credentials getDistinctCredentials() {
@@ -179,7 +201,8 @@ public class RtpContentMap {
final IceUdpTransportInfo.Credentials credentials =
Iterables.getFirst(allCredentials, null);
if (allCredentials.size() == 1 && credentials != null) {
- if (Strings.isNullOrEmpty(credentials.password) || Strings.isNullOrEmpty(credentials.ufrag)) {
+ if (Strings.isNullOrEmpty(credentials.password)
+ || Strings.isNullOrEmpty(credentials.ufrag)) {
throw new IllegalStateException("Credentials are missing password or ufrag");
}
return credentials;
@@ -220,6 +243,23 @@ public class RtpContentMap {
throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
}
+ private DTLS getDistinctDtls() {
+ final Set<DTLS> dtlsSet =
+ ImmutableSet.copyOf(
+ Collections2.transform(
+ contents.values(),
+ dt -> {
+ final IceUdpTransportInfo.Fingerprint fp =
+ dt.transport.getFingerprint();
+ return new DTLS(fp.getHash(), fp.getSetup(), fp.getContent());
+ }));
+ final DTLS dtls = Iterables.getFirst(dtlsSet, null);
+ if (dtlsSet.size() == 1 && dtls != null) {
+ return dtls;
+ }
+ throw new IllegalStateException("Content map doesn't have distinct DTLS setup");
+ }
+
public boolean emptyCandidates() {
int count = 0;
for (DescriptionTransport descriptionTransport : contents.values()) {
@@ -233,23 +273,107 @@ public class RtpContentMap {
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
new ImmutableMap.Builder<>();
for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
- final RtpDescription rtpDescription = content.getValue().description;
- IceUdpTransportInfo transportInfo = content.getValue().transport;
+ final DescriptionTransport descriptionTransport = content.getValue();
+ final RtpDescription rtpDescription = descriptionTransport.description;
+ final IceUdpTransportInfo transportInfo = descriptionTransport.transport;
final IceUdpTransportInfo modifiedTransportInfo =
transportInfo.modifyCredentials(credentials, setup);
contentMapBuilder.put(
content.getKey(),
- new DescriptionTransport(rtpDescription, modifiedTransportInfo));
+ new DescriptionTransport(
+ descriptionTransport.senders, rtpDescription, modifiedTransportInfo));
}
return new RtpContentMap(this.group, contentMapBuilder.build());
}
+ public RtpContentMap modifiedSenders(final Content.Senders senders) {
+ return new RtpContentMap(
+ this.group,
+ Maps.transformValues(
+ contents,
+ dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
+ }
+
+ public RtpContentMap toContentModification(final Collection<String> modifications) {
+ return new RtpContentMap(
+ this.group,
+ Maps.transformValues(
+ Maps.filterKeys(contents, Predicates.in(modifications)),
+ dt ->
+ new DescriptionTransport(
+ dt.senders, dt.description, IceUdpTransportInfo.STUB)));
+ }
+
+ public RtpContentMap toStub() {
+ return new RtpContentMap(
+ null,
+ Maps.transformValues(
+ this.contents,
+ dt ->
+ new DescriptionTransport(
+ dt.senders,
+ RtpDescription.stub(dt.description.getMedia()),
+ IceUdpTransportInfo.STUB)));
+ }
+
+ public RtpContentMap activeContents() {
+ return new RtpContentMap(group, Maps.filterValues(this.contents, dt -> dt.senders != Content.Senders.NONE));
+ }
+
+ public Diff diff(final RtpContentMap rtpContentMap) {
+ final Set<String> existingContentIds = this.contents.keySet();
+ final Set<String> newContentIds = rtpContentMap.contents.keySet();
+ return new Diff(
+ ImmutableSet.copyOf(Sets.difference(newContentIds, existingContentIds)),
+ ImmutableSet.copyOf(Sets.difference(existingContentIds, newContentIds)));
+ }
+
+ public boolean iceRestart(final RtpContentMap rtpContentMap) {
+ try {
+ return !getDistinctCredentials().equals(rtpContentMap.getDistinctCredentials());
+ } catch (final IllegalStateException e) {
+ return false;
+ }
+ }
+
+ public RtpContentMap addContent(
+ final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) {
+ final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials();
+ final DTLS dtls = getDistinctDtls();
+ final IceUdpTransportInfo iceUdpTransportInfo =
+ IceUdpTransportInfo.of(credentials, setup, dtls.hash, dtls.fingerprint);
+ final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
+ /*new ImmutableMap.Builder<String, DescriptionTransport>()
+ .putAll(contents)
+ .putAll(modification.contents)
+ .build();*/
+ final Map<String, DescriptionTransport> combinedFixedTransport =
+ Maps.transformValues(
+ combined,
+ dt ->
+ new DescriptionTransport(
+ dt.senders, dt.description, iceUdpTransportInfo));
+ return new RtpContentMap(modification.group, combinedFixedTransport);
+ }
+
+ private static Map<String, DescriptionTransport> merge(
+ final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
+ final Map<String, DescriptionTransport> combined = new HashMap<>();
+ combined.putAll(a);
+ combined.putAll(b);
+ return ImmutableMap.copyOf(combined);
+ }
+
public static class DescriptionTransport {
+ public final Content.Senders senders;
public final RtpDescription description;
public final IceUdpTransportInfo transport;
public DescriptionTransport(
- final RtpDescription description, final IceUdpTransportInfo transport) {
+ final Content.Senders senders,
+ final RtpDescription description,
+ final IceUdpTransportInfo transport) {
+ this.senders = senders;
this.description = description;
this.transport = transport;
}
@@ -257,6 +381,7 @@ public class RtpContentMap {
public static DescriptionTransport of(final Content content) {
final GenericDescription description = content.getDescription();
final GenericTransportInfo transportInfo = content.getTransport();
+ final Content.Senders senders = content.getSenders();
final RtpDescription rtpDescription;
final IceUdpTransportInfo iceUdpTransportInfo;
if (description == null) {
@@ -274,22 +399,26 @@ public class RtpContentMap {
"Content does not contain ICE-UDP transport");
}
return new DescriptionTransport(
- rtpDescription, OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
+ senders,
+ rtpDescription,
+ OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo));
}
- public static DescriptionTransport of(
- final SessionDescription sessionDescription, final SessionDescription.Media media) {
+ private static DescriptionTransport of(
+ final SessionDescription sessionDescription,
+ final boolean isInitiator,
+ final SessionDescription.Media media) {
+ final Content.Senders senders = Content.Senders.of(media, isInitiator);
final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media);
final IceUdpTransportInfo transportInfo =
IceUdpTransportInfo.of(sessionDescription, media);
- return new DescriptionTransport(rtpDescription, transportInfo);
+ return new DescriptionTransport(senders, rtpDescription, transportInfo);
}
public static Map<String, DescriptionTransport> of(final Map<String, Content> contents) {
return ImmutableMap.copyOf(
Maps.transformValues(
- contents,
- content -> content == null ? null : of(content)));
+ contents, content -> content == null ? null : of(content)));
}
}
@@ -304,4 +433,58 @@ public class RtpContentMap {
super(message);
}
}
+
+ public static final class Diff {
+ public final Set<String> added;
+ public final Set<String> removed;
+
+ private Diff(final Set<String> added, final Set<String> removed) {
+ this.added = added;
+ this.removed = removed;
+ }
+
+ public boolean hasModifications() {
+ return !this.added.isEmpty() || !this.removed.isEmpty();
+ }
+
+ public boolean isEmpty() {
+ return this.added.isEmpty() && this.removed.isEmpty();
+ }
+
+ @Override
+ @Nonnull
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("added", added)
+ .add("removed", removed)
+ .toString();
+ }
+ }
+
+ public static final class DTLS {
+ public final String hash;
+ public final IceUdpTransportInfo.Setup setup;
+ public final String fingerprint;
+
+ private DTLS(String hash, IceUdpTransportInfo.Setup setup, String fingerprint) {
+ this.hash = hash;
+ this.setup = setup;
+ this.fingerprint = fingerprint;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ DTLS dtls = (DTLS) o;
+ return Objects.equal(hash, dtls.hash)
+ && setup == dtls.setup
+ && Objects.equal(fingerprint, dtls.fingerprint);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(hash, setup, fingerprint);
+ }
+ }
}
@@ -5,6 +5,7 @@ public enum RtpEndUserState {
CONNECTING, //session-initiate or session-accepted but no webrtc peer connection yet
CONNECTED, //session-accepted and webrtc peer connection is connected
RECONNECTING, //session-accepted and webrtc peer connection was connected once but is currently disconnected or failed
+ INCOMING_CONTENT_ADD, //session-accepted with a pending, incoming content-add
FINDING_DEVICE, //'propose' has been sent out; no 184 ack yet
RINGING, //'propose' has been sent out and it has been 184 acked
ACCEPTING_CALL, //'proceed' message has been sent; but no session-initiate has been received
@@ -3,6 +3,8 @@ package eu.siacs.conversations.xmpp.jingle;
import android.util.Log;
import android.util.Pair;
+import androidx.annotation.NonNull;
+
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
@@ -21,11 +23,12 @@ 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";
- private final static String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
+ public static final String LINE_DIVIDER = "\r\n";
+ private static final String HARDCODED_MEDIA_PROTOCOL =
+ "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
+ private static final int HARDCODED_MEDIA_PORT = 9;
+ private static final String HARDCODED_ICE_OPTIONS = "trickle";
+ private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
public final int version;
public final String name;
@@ -33,8 +36,12 @@ public class SessionDescription {
public final ArrayListMultimap<String, String> attributes;
public final List<Media> media;
-
- public SessionDescription(int version, String name, String connectionData, ArrayListMultimap<String, String> attributes, List<Media> media) {
+ public SessionDescription(
+ int version,
+ String name,
+ String connectionData,
+ ArrayListMultimap<String, String> attributes,
+ List<Media> media) {
this.version = version;
this.name = name;
this.connectionData = connectionData;
@@ -42,7 +49,8 @@ public class SessionDescription {
this.media = media;
}
- private static void appendAttributes(StringBuilder s, ArrayListMultimap<String, String> attributes) {
+ private static void appendAttributes(
+ StringBuilder s, ArrayListMultimap<String, String> attributes) {
for (Map.Entry<String, String> attribute : attributes.entries()) {
final String key = attribute.getKey();
final String value = attribute.getValue();
@@ -109,7 +117,6 @@ public class SessionDescription {
}
break;
}
-
}
if (currentMediaBuilder != null) {
currentMediaBuilder.setAttributes(attributeMap);
@@ -121,7 +128,7 @@ public class SessionDescription {
return sessionDescriptionBuilder.createSessionDescription();
}
- public static SessionDescription of(final RtpContentMap contentMap) {
+ public static SessionDescription of(final RtpContentMap contentMap, final boolean isInitiatorContentMap) {
final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder();
final ArrayListMultimap<String, String> attributeMap = ArrayListMultimap.create();
final ImmutableList.Builder<Media> mediaListBuilder = new ImmutableList.Builder<>();
@@ -129,12 +136,17 @@ public class SessionDescription {
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(
+ "group",
+ group.getSemantics()
+ + " "
+ + Joiner.on(' ').join(group.getIdentificationTags()));
}
attributeMap.put("msid-semantic", " WMS my-media-stream");
- for (final Map.Entry<String, RtpContentMap.DescriptionTransport> entry : contentMap.contents.entrySet()) {
+ for (final Map.Entry<String, RtpContentMap.DescriptionTransport> entry :
+ contentMap.contents.entrySet()) {
final String name = entry.getKey();
RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue();
RtpDescription description = descriptionTransport.description;
@@ -143,19 +155,22 @@ public class SessionDescription {
final String ufrag = transport.getAttribute("ufrag");
final String pwd = transport.getAttribute("pwd");
if (Strings.isNullOrEmpty(ufrag)) {
- throw new IllegalArgumentException("Transport element is missing required ufrag attribute");
+ throw new IllegalArgumentException(
+ "Transport element is missing required ufrag attribute");
}
checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces");
mediaAttributes.put("ice-ufrag", ufrag);
if (Strings.isNullOrEmpty(pwd)) {
- throw new IllegalArgumentException("Transport element is missing required pwd attribute");
+ throw new IllegalArgumentException(
+ "Transport element is missing required pwd attribute");
}
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
mediaAttributes.put("ice-pwd", pwd);
mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
if (fingerprint != null) {
- mediaAttributes.put("fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
+ mediaAttributes.put(
+ "fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
if (setup != null) {
mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
@@ -174,37 +189,56 @@ public class SessionDescription {
mediaAttributes.put("rtpmap", payloadType.toSdpAttribute());
final List<RtpDescription.Parameter> parameters = payloadType.getParameters();
if (parameters.size() == 1) {
- mediaAttributes.put("fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
+ mediaAttributes.put(
+ "fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
} else if (parameters.size() > 0) {
- mediaAttributes.put("fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
+ mediaAttributes.put(
+ "fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
}
- for (RtpDescription.FeedbackNegotiation feedbackNegotiation : payloadType.getFeedbackNegotiations()) {
+ 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");
+ 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));
+ 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.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
+ payloadType.feedbackNegotiationTrrInts()) {
+ mediaAttributes.put(
+ "rtcp-fb", id + " trr-int " + feedbackNegotiationTrrInt.getValue());
}
}
- for (RtpDescription.FeedbackNegotiation feedbackNegotiation : description.getFeedbackNegotiations()) {
+ 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));
+ mediaAttributes.put(
+ "rtcp-fb",
+ "* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
}
- for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : description.feedbackNegotiationTrrInts()) {
+ for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
+ description.feedbackNegotiationTrrInts()) {
mediaAttributes.put("rtcp-fb", "* trr-int " + feedbackNegotiationTrrInt.getValue());
}
- for (final RtpDescription.RtpHeaderExtension extension : description.getHeaderExtensions()) {
+ for (final RtpDescription.RtpHeaderExtension extension :
+ description.getHeaderExtensions()) {
final String id = extension.getId();
final String uri = extension.getUri();
if (Strings.isNullOrEmpty(id)) {
@@ -218,7 +252,8 @@ public class SessionDescription {
mediaAttributes.put("extmap", id + " " + uri);
}
- if (description.hasChild("extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
+ if (description.hasChild(
+ "extmap-allow-mixed", Namespace.JINGLE_RTP_HEADER_EXTENSIONS)) {
mediaAttributes.put("extmap-allow-mixed", "");
}
@@ -226,13 +261,16 @@ public class SessionDescription {
final String semantics = sourceGroup.getSemantics();
final List<String> groups = sourceGroup.getSsrcs();
if (Strings.isNullOrEmpty(semantics)) {
- throw new IllegalArgumentException("A SSRC group is missing semantics attribute");
+ 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)));
+ mediaAttributes.put(
+ "ssrc-group",
+ String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
}
for (final RtpDescription.Source source : description.getSources()) {
for (final RtpDescription.Source.Parameter parameter : source.getParameters()) {
@@ -240,14 +278,18 @@ public class SessionDescription {
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");
+ throw new IllegalArgumentException(
+ "A source specific media attribute is missing the id");
}
- checkNoWhitespace(id, "A source specific media attributes must not contain whitespaces");
+ 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");
+ 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");
+ throw new IllegalArgumentException(
+ "A source specific media attribute is missing its value");
}
mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
}
@@ -255,14 +297,14 @@ public class SessionDescription {
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(descriptionTransport.senders.asMediaAttribute(isInitiatorContentMap), "");
+ if (description.hasChild("rtcp-mux", Namespace.JINGLE_APPS_RTP) || group != null) {
mediaAttributes.put("rtcp-mux", "");
}
+ // random additional attributes
+ mediaAttributes.put("rtcp", "9 IN IP4 0.0.0.0");
+
final MediaBuilder mediaBuilder = new MediaBuilder();
mediaBuilder.setMedia(description.getMedia().toString().toLowerCase(Locale.ROOT));
mediaBuilder.setConnectionData(HARDCODED_CONNECTION);
@@ -271,7 +313,6 @@ public class SessionDescription {
mediaBuilder.setAttributes(mediaAttributes);
mediaBuilder.setFormats(formatBuilder.build());
mediaListBuilder.add(mediaBuilder.createMedia());
-
}
sessionDescriptionBuilder.setVersion(0);
sessionDescriptionBuilder.setName("-");
@@ -317,17 +358,33 @@ public class SessionDescription {
}
}
+ @NonNull
@Override
public String toString() {
- final StringBuilder s = new StringBuilder()
- .append("v=").append(version).append(LINE_DIVIDER)
- //TODO randomize or static
- .append("o=- 8770656990916039506 2 IN IP4 127.0.0.1").append(LINE_DIVIDER) //what ever that means
- .append("s=").append(name).append(LINE_DIVIDER)
- .append("t=0 0").append(LINE_DIVIDER);
+ 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("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);
}
@@ -342,7 +399,13 @@ public class SessionDescription {
public final String connectionData;
public final ArrayListMultimap<String, String> attributes;
- public Media(String media, int port, String protocol, List<Integer> formats, String connectionData, ArrayListMultimap<String, String> attributes) {
+ public Media(
+ String media,
+ int port,
+ String protocol,
+ List<Integer> formats,
+ String connectionData,
+ ArrayListMultimap<String, String> attributes) {
this.media = media;
this.port = port;
this.protocol = protocol;
@@ -351,5 +414,4 @@ public class SessionDescription {
this.attributes = attributes;
}
}
-
}
@@ -5,6 +5,7 @@ import android.media.AudioManager;
import android.media.ToneGenerator;
import android.util.Log;
+import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@@ -19,6 +20,7 @@ class ToneManager {
private final Context context;
private ToneState state = null;
+ private RtpEndUserState endUserState = null;
private ScheduledFuture<?> currentTone;
private ScheduledFuture<?> currentResetFuture;
private boolean appRtcAudioManagerHasControl = false;
@@ -51,7 +53,11 @@ class ToneManager {
return ToneState.ENDING_CALL;
}
}
- if (state == RtpEndUserState.CONNECTED || state == RtpEndUserState.RECONNECTING) {
+ if (Arrays.asList(
+ RtpEndUserState.CONNECTED,
+ RtpEndUserState.RECONNECTING,
+ RtpEndUserState.INCOMING_CONTENT_ADD)
+ .contains(state)) {
if (media.contains(Media.VIDEO)) {
return ToneState.NULL;
} else {
@@ -62,14 +68,19 @@ class ToneManager {
}
void transition(final RtpEndUserState state, final Set<Media> media) {
- transition(of(true, state, media), media);
+ transition(state, of(true, state, media), media);
}
void transition(final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
- transition(of(isInitiator, state, media), media);
+ transition(state, of(isInitiator, state, media), media);
}
- private synchronized void transition(ToneState state, final Set<Media> media) {
+ private synchronized void transition(final RtpEndUserState endUserState, final ToneState state, final Set<Media> media) {
+ final RtpEndUserState normalizeEndUserState = normalize(endUserState);
+ if (this.endUserState == normalizeEndUserState) {
+ return;
+ }
+ this.endUserState = normalizeEndUserState;
if (this.state == state) {
return;
}
@@ -105,6 +116,18 @@ class ToneManager {
this.state = state;
}
+ private static RtpEndUserState normalize(final RtpEndUserState endUserState) {
+ if (Arrays.asList(
+ RtpEndUserState.CONNECTED,
+ RtpEndUserState.RECONNECTING,
+ RtpEndUserState.INCOMING_CONTENT_ADD)
+ .contains(endUserState)) {
+ return RtpEndUserState.CONNECTED;
+ } else {
+ return endUserState;
+ }
+ }
+
void setAppRtcAudioManagerHasControl(final boolean appRtcAudioManagerHasControl) {
this.appRtcAudioManagerHasControl = appRtcAudioManagerHasControl;
}
@@ -0,0 +1,76 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import android.util.Log;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+
+import org.webrtc.MediaStreamTrack;
+import org.webrtc.PeerConnection;
+import org.webrtc.RtpSender;
+import org.webrtc.RtpTransceiver;
+
+import java.util.UUID;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import eu.siacs.conversations.Config;
+
+class TrackWrapper<T extends MediaStreamTrack> {
+ public final T track;
+ public final RtpSender rtpSender;
+
+ private TrackWrapper(final T track, final RtpSender rtpSender) {
+ Preconditions.checkNotNull(track);
+ Preconditions.checkNotNull(rtpSender);
+ this.track = track;
+ this.rtpSender = rtpSender;
+ }
+
+ public static <T extends MediaStreamTrack> TrackWrapper<T> addTrack(
+ final PeerConnection peerConnection, final T mediaStreamTrack) {
+ final RtpSender rtpSender = peerConnection.addTrack(mediaStreamTrack);
+ return new TrackWrapper<>(mediaStreamTrack, rtpSender);
+ }
+
+ public static <T extends MediaStreamTrack> Optional<T> get(
+ @Nullable final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
+ if (trackWrapper == null) {
+ return Optional.absent();
+ }
+ final RtpTransceiver transceiver =
+ peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
+ if (transceiver == null) {
+ Log.w(Config.LOGTAG, "unable to detect transceiver for " + trackWrapper.rtpSender.id());
+ return Optional.of(trackWrapper.track);
+ }
+ final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection();
+ if (direction == RtpTransceiver.RtpTransceiverDirection.SEND_ONLY
+ || direction == RtpTransceiver.RtpTransceiverDirection.SEND_RECV) {
+ return Optional.of(trackWrapper.track);
+ } else {
+ Log.d(Config.LOGTAG, "withholding track because transceiver is " + direction);
+ return Optional.absent();
+ }
+ }
+
+ public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
+ @Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
+ final RtpSender rtpSender = trackWrapper.rtpSender;
+ for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) {
+ if (transceiver.getSender().id().equals(rtpSender.id())) {
+ return transceiver;
+ }
+ }
+ return null;
+ }
+
+ public static String id(final Class<? extends MediaStreamTrack> clazz) {
+ return String.format(
+ "%s-%s",
+ CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, clazz.getSimpleName()),
+ UUID.randomUUID().toString());
+ }
+}
@@ -0,0 +1,179 @@
+package eu.siacs.conversations.xmpp.jingle;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+
+import org.webrtc.Camera2Enumerator;
+import org.webrtc.CameraEnumerationAndroid;
+import org.webrtc.CameraEnumerator;
+import org.webrtc.CameraVideoCapturer;
+import org.webrtc.EglBase;
+import org.webrtc.PeerConnectionFactory;
+import org.webrtc.SurfaceTextureHelper;
+import org.webrtc.VideoSource;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+import eu.siacs.conversations.Config;
+
+class VideoSourceWrapper {
+
+ private static final int CAPTURING_RESOLUTION = 1920;
+ private static final int CAPTURING_MAX_FRAME_RATE = 30;
+
+ private final CameraVideoCapturer cameraVideoCapturer;
+ private final CameraEnumerationAndroid.CaptureFormat captureFormat;
+ private final Set<String> availableCameras;
+ private boolean isFrontCamera = false;
+ private VideoSource videoSource;
+
+ VideoSourceWrapper(
+ CameraVideoCapturer cameraVideoCapturer,
+ CameraEnumerationAndroid.CaptureFormat captureFormat,
+ Set<String> cameras) {
+ this.cameraVideoCapturer = cameraVideoCapturer;
+ this.captureFormat = captureFormat;
+ this.availableCameras = cameras;
+ }
+
+ private int getFrameRate() {
+ return Math.max(
+ captureFormat.framerate.min,
+ Math.min(CAPTURING_MAX_FRAME_RATE, captureFormat.framerate.max));
+ }
+
+ public void initialize(
+ final PeerConnectionFactory peerConnectionFactory,
+ final Context context,
+ final EglBase.Context eglBaseContext) {
+ final SurfaceTextureHelper surfaceTextureHelper =
+ SurfaceTextureHelper.create("webrtc", eglBaseContext);
+ this.videoSource = peerConnectionFactory.createVideoSource(false);
+ this.cameraVideoCapturer.initialize(
+ surfaceTextureHelper, context, this.videoSource.getCapturerObserver());
+ }
+
+ public VideoSource getVideoSource() {
+ final VideoSource videoSource = this.videoSource;
+ if (videoSource == null) {
+ throw new IllegalStateException("VideoSourceWrapper was not initialized");
+ }
+ return videoSource;
+ }
+
+ public void startCapture() {
+ final int frameRate = getFrameRate();
+ Log.d(
+ Config.LOGTAG,
+ String.format(
+ "start capturing at %dx%d@%d",
+ captureFormat.width, captureFormat.height, frameRate));
+ this.cameraVideoCapturer.startCapture(captureFormat.width, captureFormat.height, frameRate);
+ }
+
+ public void stopCapture() throws InterruptedException {
+ this.cameraVideoCapturer.stopCapture();
+ }
+
+ public void dispose() {
+ this.cameraVideoCapturer.dispose();
+ if (this.videoSource != null) {
+ this.videoSource.dispose();
+ }
+ }
+
+ public ListenableFuture<Boolean> switchCamera() {
+ final SettableFuture<Boolean> future = SettableFuture.create();
+ this.cameraVideoCapturer.switchCamera(
+ new CameraVideoCapturer.CameraSwitchHandler() {
+ @Override
+ public void onCameraSwitchDone(final boolean isFrontCamera) {
+ VideoSourceWrapper.this.isFrontCamera = isFrontCamera;
+ future.set(isFrontCamera);
+ }
+
+ @Override
+ public void onCameraSwitchError(final String message) {
+ future.setException(
+ new IllegalStateException(
+ String.format("Unable to switch camera %s", message)));
+ }
+ });
+ return future;
+ }
+
+ public boolean isFrontCamera() {
+ return this.isFrontCamera;
+ }
+
+ public boolean isCameraSwitchable() {
+ return this.availableCameras.size() > 1;
+ }
+
+ public static class Factory {
+ final Context context;
+
+ public Factory(final Context context) {
+ this.context = context;
+ }
+
+ public VideoSourceWrapper create() {
+ final CameraEnumerator enumerator = new Camera2Enumerator(context);
+ final Set<String> deviceNames = ImmutableSet.copyOf(enumerator.getDeviceNames());
+ for (final String deviceName : deviceNames) {
+ if (isFrontFacing(enumerator, deviceName)) {
+ final VideoSourceWrapper videoSourceWrapper =
+ of(enumerator, deviceName, deviceNames);
+ if (videoSourceWrapper == null) {
+ return null;
+ }
+ videoSourceWrapper.isFrontCamera = true;
+ return videoSourceWrapper;
+ }
+ }
+ if (deviceNames.size() == 0) {
+ return null;
+ } else {
+ return of(enumerator, Iterables.get(deviceNames, 0), deviceNames);
+ }
+ }
+
+ @Nullable
+ private VideoSourceWrapper of(
+ final CameraEnumerator enumerator,
+ final String deviceName,
+ final Set<String> availableCameras) {
+ final CameraVideoCapturer capturer = enumerator.createCapturer(deviceName, null);
+ if (capturer == null) {
+ return null;
+ }
+ final ArrayList<CameraEnumerationAndroid.CaptureFormat> choices =
+ new ArrayList<>(enumerator.getSupportedFormats(deviceName));
+ Collections.sort(choices, (a, b) -> b.width - a.width);
+ for (final CameraEnumerationAndroid.CaptureFormat captureFormat : choices) {
+ if (captureFormat.width <= CAPTURING_RESOLUTION) {
+ return new VideoSourceWrapper(capturer, captureFormat, availableCameras);
+ }
+ }
+ return null;
+ }
+
+ private static boolean isFrontFacing(
+ final CameraEnumerator cameraEnumerator, final String deviceName) {
+ try {
+ return cameraEnumerator.isFrontFacing(deviceName);
+ } catch (final NullPointerException e) {
+ return false;
+ }
+ }
+ }
+}
@@ -11,7 +11,6 @@ import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@@ -19,10 +18,6 @@ import com.google.common.util.concurrent.SettableFuture;
import org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
-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;
@@ -39,14 +34,10 @@ 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 org.webrtc.audio.JavaAudioDeviceModule;
import org.webrtc.voiceengine.WebRtcAudioEffects;
-import java.util.ArrayList;
-import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
@@ -63,30 +54,13 @@ import eu.siacs.conversations.Config;
import eu.siacs.conversations.services.AppRTCAudioManager;
import eu.siacs.conversations.services.XmppConnectionService;
+@SuppressWarnings("UnstableApiUsage")
public class WebRTCWrapper {
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
private final ExecutorService executorService = Executors.newSingleThreadExecutor();
- private static final Set<String> HARDWARE_AEC_BLACKLIST = new ImmutableSet.Builder<String>()
- .add("Pixel")
- .add("Pixel XL")
- .add("Moto G5")
- .add("Moto G (5S) Plus")
- .add("Moto G4")
- .add("TA-1053")
- .add("Mi A1")
- .add("Mi A2")
- .add("E5823") // Sony z5 compact
- .add("Redmi Note 5")
- .add("FP2") // Fairphone FP2
- .add("MI 5")
- .add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
- .add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
- .add("GT-I9505") // Samsung Galaxy S4 (jfltexx)
- .build();
-
private static final int TONE_DURATION = 500;
private static final Map<String,Integer> TONE_CODES;
static {
@@ -106,116 +80,159 @@ public class WebRTCWrapper {
TONE_CODES = builder.build();
}
- private static final int CAPTURING_RESOLUTION = 1920;
- private static final int CAPTURING_MAX_FRAME_RATE = 30;
+ private static final Set<String> HARDWARE_AEC_BLACKLIST =
+ new ImmutableSet.Builder<String>()
+ .add("Pixel")
+ .add("Pixel XL")
+ .add("Moto G5")
+ .add("Moto G (5S) Plus")
+ .add("Moto G4")
+ .add("TA-1053")
+ .add("Mi A1")
+ .add("Mi A2")
+ .add("E5823") // Sony z5 compact
+ .add("Redmi Note 5")
+ .add("FP2") // Fairphone FP2
+ .add("MI 5")
+ .add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
+ .add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
+ .add("GT-I9505") // Samsung Galaxy S4 (jfltexx)
+ .build();
private final EventCallback eventCallback;
private final AtomicBoolean readyToReceivedIceCandidates = new AtomicBoolean(false);
private final Queue<IceCandidate> iceCandidates = new LinkedList<>();
- private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents = new AppRTCAudioManager.AudioManagerEvents() {
- @Override
- public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
- eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
- }
- };
+ private final AppRTCAudioManager.AudioManagerEvents audioManagerEvents =
+ new AppRTCAudioManager.AudioManagerEvents() {
+ @Override
+ public void onAudioDeviceChanged(
+ AppRTCAudioManager.AudioDevice selectedAudioDevice,
+ Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+ eventCallback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
+ }
+ };
private final Handler mainHandler = new Handler(Looper.getMainLooper());
- private VideoTrack localVideoTrack = null;
+ private TrackWrapper<AudioTrack> localAudioTrack = null;
+ private TrackWrapper<VideoTrack> localVideoTrack = null;
private VideoTrack remoteVideoTrack = null;
- private final PeerConnection.Observer peerConnectionObserver = new PeerConnection.Observer() {
- @Override
- public void onSignalingChange(PeerConnection.SignalingState signalingState) {
- Log.d(EXTENDED_LOGGING_TAG, "onSignalingChange(" + signalingState + ")");
- //this is called after removeTrack or addTrack
- //and should then trigger a content-add or content-remove or something
- //https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/removeTrack
- }
-
- @Override
- public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
- eventCallback.onConnectionChange(newState);
- }
-
- @Override
- public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
- Log.d(EXTENDED_LOGGING_TAG, "onIceConnectionChange(" + iceConnectionState + ")");
- }
-
- @Override
- public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) {
- Log.d(Config.LOGTAG, "remote candidate selected: " + event.remote);
- Log.d(Config.LOGTAG, "local candidate selected: " + event.local);
- }
+ 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 onIceConnectionReceivingChange(boolean b) {
+ @Override
+ public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
+ eventCallback.onConnectionChange(newState);
+ }
- }
+ @Override
+ public void onIceConnectionChange(
+ PeerConnection.IceConnectionState iceConnectionState) {
+ Log.d(
+ EXTENDED_LOGGING_TAG,
+ "onIceConnectionChange(" + iceConnectionState + ")");
+ }
- @Override
- public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
- Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
- }
+ @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 onIceCandidate(IceCandidate iceCandidate) {
- if (readyToReceivedIceCandidates.get()) {
- eventCallback.onIceCandidate(iceCandidate);
- } else {
- iceCandidates.add(iceCandidate);
- }
- }
+ @Override
+ public void onIceConnectionReceivingChange(boolean b) {}
- @Override
- public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
+ @Override
+ public void onIceGatheringChange(
+ PeerConnection.IceGatheringState iceGatheringState) {
+ Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
+ }
- }
+ @Override
+ public void onIceCandidate(IceCandidate iceCandidate) {
+ if (readyToReceivedIceCandidates.get()) {
+ eventCallback.onIceCandidate(iceCandidate);
+ } else {
+ iceCandidates.add(iceCandidate);
+ }
+ }
- @Override
- public void onAddStream(MediaStream mediaStream) {
- Log.d(EXTENDED_LOGGING_TAG, "onAddStream(numAudioTracks=" + mediaStream.audioTracks.size() + ",numVideoTracks=" + mediaStream.videoTracks.size() + ")");
- }
+ @Override
+ public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}
- @Override
- public void onRemoveStream(MediaStream mediaStream) {
+ @Override
+ public void onAddStream(MediaStream mediaStream) {
+ Log.d(
+ EXTENDED_LOGGING_TAG,
+ "onAddStream(numAudioTracks="
+ + mediaStream.audioTracks.size()
+ + ",numVideoTracks="
+ + mediaStream.videoTracks.size()
+ + ")");
+ }
- }
+ @Override
+ public void onRemoveStream(MediaStream mediaStream) {}
- @Override
- public void onDataChannel(DataChannel dataChannel) {
+ @Override
+ public void onDataChannel(DataChannel dataChannel) {}
- }
+ @Override
+ public void onRenegotiationNeeded() {
+ Log.d(EXTENDED_LOGGING_TAG, "onRenegotiationNeeded()");
+ final PeerConnection.PeerConnectionState currentState =
+ peerConnection == null ? null : peerConnection.connectionState();
+ if (currentState != null
+ && currentState != PeerConnection.PeerConnectionState.NEW) {
+ eventCallback.onRenegotiationNeeded();
+ }
+ }
- @Override
- public void onRenegotiationNeeded() {
- Log.d(EXTENDED_LOGGING_TAG, "onRenegotiationNeeded()");
- final PeerConnection.PeerConnectionState currentState = peerConnection == null ? null : peerConnection.connectionState();
- if (currentState != null && currentState != PeerConnection.PeerConnectionState.NEW) {
- eventCallback.onRenegotiationNeeded();
- }
- }
+ @Override
+ public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
+ final MediaStreamTrack track = rtpReceiver.track();
+ Log.d(
+ EXTENDED_LOGGING_TAG,
+ "onAddTrack(kind="
+ + (track == null ? "null" : track.kind())
+ + ",numMediaStreams="
+ + mediaStreams.length
+ + ")");
+ if (track instanceof VideoTrack) {
+ remoteVideoTrack = (VideoTrack) track;
+ }
+ }
- @Override
- public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
- final MediaStreamTrack track = rtpReceiver.track();
- Log.d(EXTENDED_LOGGING_TAG, "onAddTrack(kind=" + (track == null ? "null" : track.kind()) + ",numMediaStreams=" + mediaStreams.length + ")");
- if (track instanceof VideoTrack) {
- remoteVideoTrack = (VideoTrack) track;
- }
- }
+ @Override
+ public void onTrack(final RtpTransceiver transceiver) {
+ Log.d(
+ EXTENDED_LOGGING_TAG,
+ "onTrack(mid="
+ + transceiver.getMid()
+ + ",media="
+ + transceiver.getMediaType()
+ + ",direction="
+ + transceiver.getDirection()
+ + ")");
+ }
- @Override
- public void onTrack(RtpTransceiver transceiver) {
- Log.d(EXTENDED_LOGGING_TAG, "onTrack(mid=" + transceiver.getMid() + ",media=" + transceiver.getMediaType() + ")");
- }
- };
- @Nullable
- private PeerConnection peerConnection = null;
- private AudioTrack localAudioTrack = null;
+ @Override
+ public void onRemoveTrack(final RtpReceiver receiver) {
+ Log.d(EXTENDED_LOGGING_TAG, "onRemoveTrack(" + receiver.id() + ")");
+ }
+ };
+ @Nullable private PeerConnectionFactory peerConnectionFactory = null;
+ @Nullable private PeerConnection peerConnection = null;
private AppRTCAudioManager appRTCAudioManager = null;
private ToneManager toneManager = null;
private Context context = null;
private EglBase eglBase = null;
- private CapturerChoice capturerChoice;
+ private VideoSourceWrapper videoSourceWrapper;
WebRTCWrapper(final EventCallback eventCallback) {
this.eventCallback = eventCallback;
@@ -229,37 +246,15 @@ public class WebRTCWrapper {
}
}
- @Nullable
- private static CapturerChoice of(CameraEnumerator enumerator, final String deviceName, Set<String> availableCameras) {
- final CameraVideoCapturer capturer = enumerator.createCapturer(deviceName, null);
- if (capturer == null) {
- return null;
- }
- final ArrayList<CameraEnumerationAndroid.CaptureFormat> choices = new ArrayList<>(enumerator.getSupportedFormats(deviceName));
- Collections.sort(choices, (a, b) -> b.width - a.width);
- for (final CameraEnumerationAndroid.CaptureFormat captureFormat : choices) {
- if (captureFormat.width <= CAPTURING_RESOLUTION) {
- return new CapturerChoice(capturer, captureFormat, availableCameras);
- }
- }
- return null;
- }
-
- private static boolean isFrontFacing(final CameraEnumerator cameraEnumerator, final String deviceName) {
- try {
- return cameraEnumerator.isFrontFacing(deviceName);
- } catch (final NullPointerException e) {
- return false;
- }
- }
-
- public void setup(final XmppConnectionService service, final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference) throws InitializationException {
+ public void setup(
+ final XmppConnectionService service,
+ @Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
+ throws InitializationException {
try {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(service)
- .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/")
- .createInitializationOptions()
- );
+ .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/")
+ .createInitializationOptions());
} catch (final UnsatisfiedLinkError e) {
throw new InitializationException("Unable to initialize PeerConnectionFactory", e);
}
@@ -270,68 +265,168 @@ public class WebRTCWrapper {
}
this.context = service;
this.toneManager = service.getJingleConnectionManager().toneManager;
- mainHandler.post(() -> {
- appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
- toneManager.setAppRtcAudioManagerHasControl(true);
- appRTCAudioManager.start(audioManagerEvents);
- eventCallback.onAudioDeviceChanged(appRTCAudioManager.getSelectedAudioDevice(), appRTCAudioManager.getAudioDevices());
- });
- }
-
- synchronized void initializePeerConnection(final Set<Media> media, final List<PeerConnection.IceServer> iceServers) throws InitializationException {
+ mainHandler.post(
+ () -> {
+ appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
+ toneManager.setAppRtcAudioManagerHasControl(true);
+ appRTCAudioManager.start(audioManagerEvents);
+ eventCallback.onAudioDeviceChanged(
+ appRTCAudioManager.getSelectedAudioDevice(),
+ appRTCAudioManager.getAudioDevices());
+ });
+ }
+
+ synchronized void initializePeerConnection(
+ final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
+ throws InitializationException {
Preconditions.checkState(this.eglBase != null);
Preconditions.checkNotNull(media);
- Preconditions.checkArgument(media.size() > 0, "media can not be empty when initializing peer connection");
- final boolean setUseHardwareAcousticEchoCanceler = WebRtcAudioEffects.canUseAcousticEchoCanceler() && !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
- Log.d(Config.LOGTAG, String.format("setUseHardwareAcousticEchoCanceler(%s) model=%s", setUseHardwareAcousticEchoCanceler, Build.MODEL));
- PeerConnectionFactory peerConnectionFactory = PeerConnectionFactory.builder()
- .setVideoDecoderFactory(new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
- .setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true))
- .setAudioDeviceModule(JavaAudioDeviceModule.builder(context)
- .setUseHardwareAcousticEchoCanceler(setUseHardwareAcousticEchoCanceler)
- .createAudioDeviceModule()
- )
- .createPeerConnectionFactory();
-
+ Preconditions.checkArgument(
+ media.size() > 0, "media can not be empty when initializing peer connection");
+ final boolean setUseHardwareAcousticEchoCanceler =
+ WebRtcAudioEffects.canUseAcousticEchoCanceler()
+ && !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
+ Log.d(
+ Config.LOGTAG,
+ String.format(
+ "setUseHardwareAcousticEchoCanceler(%s) model=%s",
+ setUseHardwareAcousticEchoCanceler, Build.MODEL));
+ this.peerConnectionFactory =
+ PeerConnectionFactory.builder()
+ .setVideoDecoderFactory(
+ new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()))
+ .setVideoEncoderFactory(
+ new DefaultVideoEncoderFactory(
+ eglBase.getEglBaseContext(), true, true))
+ .setAudioDeviceModule(
+ JavaAudioDeviceModule.builder(requireContext())
+ .setUseHardwareAcousticEchoCanceler(
+ setUseHardwareAcousticEchoCanceler)
+ .createAudioDeviceModule())
+ .createPeerConnectionFactory();
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers);
- final PeerConnection peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, peerConnectionObserver);
+ final PeerConnection peerConnection =
+ requirePeerConnectionFactory()
+ .createPeerConnection(rtcConfig, peerConnectionObserver);
if (peerConnection == null) {
throw new InitializationException("Unable to create PeerConnection");
}
- final Optional<CapturerChoice> optionalCapturerChoice = media.contains(Media.VIDEO) ? getVideoCapturer() : Optional.absent();
-
- if (optionalCapturerChoice.isPresent()) {
- this.capturerChoice = optionalCapturerChoice.get();
- final CameraVideoCapturer capturer = this.capturerChoice.cameraVideoCapturer;
- final VideoSource videoSource = peerConnectionFactory.createVideoSource(false);
- SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("webrtc", eglBase.getEglBaseContext());
- capturer.initialize(surfaceTextureHelper, requireContext(), videoSource.getCapturerObserver());
- Log.d(Config.LOGTAG, String.format("start capturing at %dx%d@%d", capturerChoice.captureFormat.width, capturerChoice.captureFormat.height, capturerChoice.getFrameRate()));
- capturer.startCapture(capturerChoice.captureFormat.width, capturerChoice.captureFormat.height, capturerChoice.getFrameRate());
-
- this.localVideoTrack = peerConnectionFactory.createVideoTrack("my-video-track", videoSource);
-
- peerConnection.addTrack(this.localVideoTrack);
+ if (media.contains(Media.VIDEO)) {
+ addVideoTrack(peerConnection);
}
-
if (media.contains(Media.AUDIO)) {
- //set up audio track
- final AudioSource audioSource = peerConnectionFactory.createAudioSource(new MediaConstraints());
- this.localAudioTrack = peerConnectionFactory.createAudioTrack("my-audio-track", audioSource);
- peerConnection.addTrack(this.localAudioTrack);
+ addAudioTrack(peerConnection);
}
peerConnection.setAudioPlayout(true);
peerConnection.setAudioRecording(true);
+
this.peerConnection = peerConnection;
}
- private static PeerConnection.RTCConfiguration buildConfiguration(final List<PeerConnection.IceServer> iceServers) {
- final PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers);
- rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; //XEP-0176 doesn't support tcp
- rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
+ private VideoSourceWrapper initializeVideoSourceWrapper() {
+ final VideoSourceWrapper existingVideoSourceWrapper = this.videoSourceWrapper;
+ if (existingVideoSourceWrapper != null) {
+ existingVideoSourceWrapper.startCapture();
+ return existingVideoSourceWrapper;
+ }
+ final VideoSourceWrapper videoSourceWrapper =
+ new VideoSourceWrapper.Factory(requireContext()).create();
+ if (videoSourceWrapper == null) {
+ throw new IllegalStateException("Could not instantiate VideoSourceWrapper");
+ }
+ videoSourceWrapper.initialize(
+ requirePeerConnectionFactory(), requireContext(), eglBase.getEglBaseContext());
+ videoSourceWrapper.startCapture();
+ this.videoSourceWrapper = videoSourceWrapper;
+ return videoSourceWrapper;
+ }
+
+ public synchronized boolean addTrack(final Media media) {
+ if (media == Media.VIDEO) {
+ return addVideoTrack(requirePeerConnection());
+ } else if (media == Media.AUDIO) {
+ return addAudioTrack(requirePeerConnection());
+ }
+ throw new IllegalStateException(String.format("Could not add track for %s", media));
+ }
+
+ public synchronized void removeTrack(final Media media) {
+ if (media == Media.VIDEO) {
+ removeVideoTrack(requirePeerConnection());
+ }
+ }
+
+ private boolean addAudioTrack(final PeerConnection peerConnection) {
+ final AudioSource audioSource =
+ requirePeerConnectionFactory().createAudioSource(new MediaConstraints());
+ final AudioTrack audioTrack =
+ requirePeerConnectionFactory()
+ .createAudioTrack(TrackWrapper.id(AudioTrack.class), audioSource);
+ this.localAudioTrack = TrackWrapper.addTrack(peerConnection, audioTrack);
+ return true;
+ }
+
+ private boolean addVideoTrack(final PeerConnection peerConnection) {
+ final TrackWrapper<VideoTrack> existing = this.localVideoTrack;
+ if (existing != null) {
+ final RtpTransceiver transceiver =
+ TrackWrapper.getTransceiver(peerConnection, existing);
+ if (transceiver == null) {
+ Log.w(EXTENDED_LOGGING_TAG, "unable to restart video transceiver");
+ return false;
+ }
+ transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_RECV);
+ this.videoSourceWrapper.startCapture();
+ return true;
+ }
+ final VideoSourceWrapper videoSourceWrapper;
+ try {
+ videoSourceWrapper = initializeVideoSourceWrapper();
+ } catch (final IllegalStateException e) {
+ Log.d(Config.LOGTAG, "could not add video track", e);
+ return false;
+ }
+ final VideoTrack videoTrack =
+ requirePeerConnectionFactory()
+ .createVideoTrack(
+ TrackWrapper.id(VideoTrack.class),
+ videoSourceWrapper.getVideoSource());
+ this.localVideoTrack = TrackWrapper.addTrack(peerConnection, videoTrack);
+ return true;
+ }
+
+ private void removeVideoTrack(final PeerConnection peerConnection) {
+ final TrackWrapper<VideoTrack> localVideoTrack = this.localVideoTrack;
+ if (localVideoTrack != null) {
+
+ final RtpTransceiver exactTransceiver =
+ TrackWrapper.getTransceiver(peerConnection, localVideoTrack);
+ if (exactTransceiver == null) {
+ throw new IllegalStateException();
+ }
+ exactTransceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.INACTIVE);
+ }
+ final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+ if (videoSourceWrapper != null) {
+ try {
+ videoSourceWrapper.stopCapture();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ private static PeerConnection.RTCConfiguration buildConfiguration(
+ final List<PeerConnection.IceServer> iceServers) {
+ final PeerConnection.RTCConfiguration rtcConfig =
+ new PeerConnection.RTCConfiguration(iceServers);
+ rtcConfig.tcpCandidatePolicy =
+ PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
+ rtcConfig.continualGatheringPolicy =
+ PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
rtcConfig.enableImplicitRollback = true;
@@ -343,7 +438,20 @@ public class WebRTCWrapper {
}
void restartIce() {
- executorService.execute(() -> requirePeerConnection().restartIce());
+ executorService.execute(
+ () -> {
+ final PeerConnection peerConnection;
+ try {
+ peerConnection = requirePeerConnection();
+ } catch (final PeerConnectionNotInitialized e) {
+ Log.w(
+ EXTENDED_LOGGING_TAG,
+ "PeerConnection vanished before we could execute restart");
+ return;
+ }
+ setIsReadyToReceiveIceCandidates(false);
+ peerConnection.restartIce();
+ });
}
public void setIsReadyToReceiveIceCandidates(final boolean ready) {
@@ -355,12 +463,13 @@ public class WebRTCWrapper {
synchronized void close() {
final PeerConnection peerConnection = this.peerConnection;
- final CapturerChoice capturerChoice = this.capturerChoice;
+ final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
+ final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
final AppRTCAudioManager audioManager = this.appRTCAudioManager;
final EglBase eglBase = this.eglBase;
if (peerConnection != null) {
- dispose(peerConnection);
this.peerConnection = null;
+ dispose(peerConnection);
}
if (audioManager != null) {
toneManager.setAppRtcAudioManagerHasControl(false);
@@ -368,17 +477,22 @@ public class WebRTCWrapper {
}
this.localVideoTrack = null;
this.remoteVideoTrack = null;
- if (capturerChoice != null) {
+ if (videoSourceWrapper != null) {
try {
- capturerChoice.cameraVideoCapturer.stopCapture();
- } catch (InterruptedException e) {
+ videoSourceWrapper.stopCapture();
+ } catch (final InterruptedException e) {
Log.e(Config.LOGTAG, "unable to stop capturing");
}
+ videoSourceWrapper.dispose();
}
if (eglBase != null) {
eglBase.release();
this.eglBase = null;
}
+ if (peerConnectionFactory != null) {
+ this.peerConnectionFactory = null;
+ peerConnectionFactory.dispose();
+ }
}
synchronized void verifyClosed() {
@@ -386,132 +500,152 @@ public class WebRTCWrapper {
|| this.eglBase != null
|| this.localVideoTrack != null
|| this.remoteVideoTrack != null) {
- final IllegalStateException e = new IllegalStateException("WebRTCWrapper hasn't been closed properly");
+ final IllegalStateException e =
+ new IllegalStateException("WebRTCWrapper hasn't been closed properly");
Log.e(Config.LOGTAG, "verifyClosed() failed. Going to throw", e);
throw e;
}
}
boolean isCameraSwitchable() {
- final CapturerChoice capturerChoice = this.capturerChoice;
- return capturerChoice != null && capturerChoice.availableCameras.size() > 1;
+ final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+ return videoSourceWrapper != null && videoSourceWrapper.isCameraSwitchable();
}
boolean isFrontCamera() {
- final CapturerChoice capturerChoice = this.capturerChoice;
- return capturerChoice == null || capturerChoice.isFrontCamera;
+ final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+ return videoSourceWrapper == null || videoSourceWrapper.isFrontCamera();
}
ListenableFuture<Boolean> switchCamera() {
- final CapturerChoice capturerChoice = this.capturerChoice;
- if (capturerChoice == null) {
- return Futures.immediateFailedFuture(new IllegalStateException("CameraCapturer has not been initialized"));
- }
- final SettableFuture<Boolean> future = SettableFuture.create();
- capturerChoice.cameraVideoCapturer.switchCamera(new CameraVideoCapturer.CameraSwitchHandler() {
- @Override
- public void onCameraSwitchDone(boolean isFrontCamera) {
- capturerChoice.isFrontCamera = isFrontCamera;
- future.set(isFrontCamera);
- }
-
- @Override
- public void onCameraSwitchError(final String message) {
- future.setException(new IllegalStateException(String.format("Unable to switch camera %s", message)));
- }
- });
- return future;
+ final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
+ if (videoSourceWrapper == null) {
+ return Futures.immediateFailedFuture(
+ new IllegalStateException("VideoSourceWrapper has not been initialized"));
+ }
+ return videoSourceWrapper.switchCamera();
}
boolean isMicrophoneEnabled() {
- final AudioTrack audioTrack = this.localAudioTrack;
- if (audioTrack == null) {
+ final Optional<AudioTrack> audioTrack =
+ TrackWrapper.get(peerConnection, this.localAudioTrack);
+ if (audioTrack.isPresent()) {
+ try {
+ return audioTrack.get().enabled();
+ } catch (final IllegalStateException e) {
+ // sometimes UI might still be rendering the buttons when a background thread has
+ // already ended the call
+ return false;
+ }
+ } else {
throw new IllegalStateException("Local audio track does not exist (yet)");
}
- try {
- return audioTrack.enabled();
- } catch (final IllegalStateException e) {
- //sometimes UI might still be rendering the buttons when a background thread has already ended the call
- return false;
- }
}
boolean setMicrophoneEnabled(final boolean enabled) {
- final AudioTrack audioTrack = this.localAudioTrack;
- if (audioTrack == null) {
+ final Optional<AudioTrack> audioTrack =
+ TrackWrapper.get(peerConnection, this.localAudioTrack);
+ if (audioTrack.isPresent()) {
+ try {
+ audioTrack.get().setEnabled(enabled);
+ return true;
+ } catch (final IllegalStateException e) {
+ Log.d(Config.LOGTAG, "unable to toggle microphone", e);
+ // ignoring race condition in case MediaStreamTrack has been disposed
+ return false;
+ }
+ } else {
throw new IllegalStateException("Local audio track does not exist (yet)");
}
- try {
- audioTrack.setEnabled(enabled);
- return true;
- } catch (final IllegalStateException e) {
- Log.d(Config.LOGTAG, "unable to toggle microphone", e);
- //ignoring race condition in case MediaStreamTrack has been disposed
- return false;
- }
}
boolean isVideoEnabled() {
- final VideoTrack videoTrack = this.localVideoTrack;
- if (videoTrack == null) {
- return false;
+ final Optional<VideoTrack> videoTrack =
+ TrackWrapper.get(peerConnection, this.localVideoTrack);
+ if (videoTrack.isPresent()) {
+ return videoTrack.get().enabled();
}
- return videoTrack.enabled();
+ return false;
}
void setVideoEnabled(final boolean enabled) {
- final VideoTrack videoTrack = this.localVideoTrack;
- if (videoTrack == null) {
- throw new IllegalStateException("Local video track does not exist");
+ final Optional<VideoTrack> videoTrack =
+ TrackWrapper.get(peerConnection, this.localVideoTrack);
+ if (videoTrack.isPresent()) {
+ videoTrack.get().setEnabled(enabled);
+ return;
}
- videoTrack.setEnabled(enabled);
+ throw new IllegalStateException("Local video track does not exist");
}
synchronized ListenableFuture<SessionDescription> setLocalDescription() {
- return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
- final SettableFuture<SessionDescription> future = SettableFuture.create();
- peerConnection.setLocalDescription(new SetSdpObserver() {
- @Override
- public void onSetSuccess() {
- final SessionDescription description = peerConnection.getLocalDescription();
- Log.d(EXTENDED_LOGGING_TAG, "set local description:");
- logDescription(description);
- future.set(description);
- }
-
- @Override
- public void onSetFailure(final String message) {
- future.setException(new FailureToSetDescriptionException(message));
- }
- });
- return future;
- }, MoreExecutors.directExecutor());
- }
-
- private static void logDescription(final SessionDescription sessionDescription) {
- for (final String line : sessionDescription.description.split(eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
+ return Futures.transformAsync(
+ getPeerConnectionFuture(),
+ peerConnection -> {
+ if (peerConnection == null) {
+ return Futures.immediateFailedFuture(
+ new IllegalStateException("PeerConnection was null"));
+ }
+ final SettableFuture<SessionDescription> future = SettableFuture.create();
+ peerConnection.setLocalDescription(
+ new SetSdpObserver() {
+ @Override
+ public void onSetSuccess() {
+ final SessionDescription description =
+ peerConnection.getLocalDescription();
+ Log.d(EXTENDED_LOGGING_TAG, "set local description:");
+ logDescription(description);
+ future.set(description);
+ }
+
+ @Override
+ public void onSetFailure(final String message) {
+ future.setException(
+ new FailureToSetDescriptionException(message));
+ }
+ });
+ return future;
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ public static void logDescription(final SessionDescription sessionDescription) {
+ for (final String line :
+ sessionDescription.description.split(
+ eu.siacs.conversations.xmpp.jingle.SessionDescription.LINE_DIVIDER)) {
Log.d(EXTENDED_LOGGING_TAG, line);
}
}
- synchronized ListenableFuture<Void> setRemoteDescription(final SessionDescription sessionDescription) {
+ synchronized ListenableFuture<Void> setRemoteDescription(
+ final SessionDescription sessionDescription) {
Log.d(EXTENDED_LOGGING_TAG, "setting remote description:");
logDescription(sessionDescription);
- return Futures.transformAsync(getPeerConnectionFuture(), peerConnection -> {
- final SettableFuture<Void> future = SettableFuture.create();
- peerConnection.setRemoteDescription(new SetSdpObserver() {
- @Override
- public void onSetSuccess() {
- future.set(null);
- }
-
- @Override
- public void onSetFailure(final String message) {
- future.setException(new FailureToSetDescriptionException(message));
- }
- }, sessionDescription);
- return future;
- }, MoreExecutors.directExecutor());
+ return Futures.transformAsync(
+ getPeerConnectionFuture(),
+ peerConnection -> {
+ if (peerConnection == null) {
+ return Futures.immediateFailedFuture(
+ new IllegalStateException("PeerConnection was null"));
+ }
+ final SettableFuture<Void> future = SettableFuture.create();
+ peerConnection.setRemoteDescription(
+ new SetSdpObserver() {
+ @Override
+ public void onSetSuccess() {
+ future.set(null);
+ }
+
+ @Override
+ public void onSetFailure(final String message) {
+ future.setException(
+ new FailureToSetDescriptionException(message));
+ }
+ },
+ sessionDescription);
+ return future;
+ },
+ MoreExecutors.directExecutor());
}
@Nonnull
@@ -524,6 +658,7 @@ public class WebRTCWrapper {
}
}
+ @Nonnull
private PeerConnection requirePeerConnection() {
final PeerConnection peerConnection = this.peerConnection;
if (peerConnection == null) {
@@ -541,28 +676,17 @@ public class WebRTCWrapper {
return true;
}
- void addIceCandidate(IceCandidate iceCandidate) {
- requirePeerConnection().addIceCandidate(iceCandidate);
+ @Nonnull
+ private PeerConnectionFactory requirePeerConnectionFactory() {
+ final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
+ if (peerConnectionFactory == null) {
+ throw new IllegalStateException("Make sure PeerConnectionFactory is initialized");
+ }
+ return peerConnectionFactory;
}
- private Optional<CapturerChoice> getVideoCapturer() {
- final CameraEnumerator enumerator = new Camera2Enumerator(requireContext());
- final Set<String> deviceNames = ImmutableSet.copyOf(enumerator.getDeviceNames());
- for (final String deviceName : deviceNames) {
- if (isFrontFacing(enumerator, deviceName)) {
- final CapturerChoice capturerChoice = of(enumerator, deviceName, deviceNames);
- if (capturerChoice == null) {
- return Optional.absent();
- }
- capturerChoice.isFrontCamera = true;
- return Optional.of(capturerChoice);
- }
- }
- if (deviceNames.size() == 0) {
- return Optional.absent();
- } else {
- return Optional.fromNullable(of(enumerator, Iterables.get(deviceNames, 0), deviceNames));
- }
+ void addIceCandidate(IceCandidate iceCandidate) {
+ requirePeerConnection().addIceCandidate(iceCandidate);
}
PeerConnection.PeerConnectionState getState() {
@@ -573,13 +697,12 @@ public class WebRTCWrapper {
return requirePeerConnection().signalingState();
}
-
EglBase.Context getEglBaseContext() {
return this.eglBase.getEglBaseContext();
}
Optional<VideoTrack> getLocalVideoTrack() {
- return Optional.fromNullable(this.localVideoTrack);
+ return TrackWrapper.get(peerConnection, this.localVideoTrack);
}
Optional<VideoTrack> getRemoteVideoTrack() {
@@ -602,17 +725,23 @@ public class WebRTCWrapper {
executorService.execute(command);
}
+ public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
+ mainHandler.post(() -> appRTCAudioManager.switchSpeakerPhonePreference(preference));
+ }
+
public interface EventCallback {
void onIceCandidate(IceCandidate iceCandidate);
void onConnectionChange(PeerConnection.PeerConnectionState newState);
- void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
+ void onAudioDeviceChanged(
+ AppRTCAudioManager.AudioDevice selectedAudioDevice,
+ Set<AppRTCAudioManager.AudioDevice> availableAudioDevices);
void onRenegotiationNeeded();
}
- private static abstract class SetSdpObserver implements SdpObserver {
+ private abstract static class SetSdpObserver implements SdpObserver {
@Override
public void onCreateSuccess(org.webrtc.SessionDescription sessionDescription) {
@@ -623,22 +752,6 @@ public class WebRTCWrapper {
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");
- }
}
static class InitializationException extends Exception {
@@ -657,7 +770,6 @@ public class WebRTCWrapper {
private PeerConnectionNotInitialized() {
super("initialize PeerConnection first");
}
-
}
private static class FailureToSetDescriptionException extends IllegalArgumentException {
@@ -665,21 +777,4 @@ public class WebRTCWrapper {
super(message);
}
}
-
- private static class CapturerChoice {
- private final CameraVideoCapturer cameraVideoCapturer;
- private final CameraEnumerationAndroid.CaptureFormat captureFormat;
- private final Set<String> availableCameras;
- private boolean isFrontCamera = false;
-
- CapturerChoice(CameraVideoCapturer cameraVideoCapturer, CameraEnumerationAndroid.CaptureFormat captureFormat, Set<String> cameras) {
- this.cameraVideoCapturer = cameraVideoCapturer;
- this.captureFormat = captureFormat;
- this.availableCameras = cameras;
- }
-
- int getFrameRate() {
- return Math.max(captureFormat.framerate.min, Math.min(CAPTURING_MAX_FRAME_RATE, captureFormat.framerate.max));
- }
- }
}
@@ -1,20 +1,27 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
+import android.util.Log;
+
import androidx.annotation.NonNull;
import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
import java.util.Locale;
+import java.util.Set;
+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 Content extends Element {
- public Content(final Creator creator, final String name) {
+ public Content(final Creator creator, final Senders senders, final String name) {
super("content", Namespace.JINGLE);
this.setAttribute("creator", creator.toString());
this.setAttribute("name", name);
+ this.setSenders(senders);
}
private Content() {
@@ -38,11 +45,17 @@ public class Content extends Element {
}
public Senders getSenders() {
+ final String attribute = getAttribute("senders");
+ if (Strings.isNullOrEmpty(attribute)) {
+ return Senders.BOTH;
+ }
return Senders.of(getAttribute("senders"));
}
- public void setSenders(Senders senders) {
- this.setAttribute("senders", senders.toString());
+ public void setSenders(final Senders senders) {
+ if (senders != null && senders != Senders.BOTH) {
+ this.setAttribute("senders", senders.toString());
+ }
}
public GenericDescription getDescription() {
@@ -51,9 +64,7 @@ public class Content extends Element {
return null;
}
final String namespace = description.getNamespace();
- if (FileTransferDescription.NAMESPACES.contains(namespace)) {
- return FileTransferDescription.upgrade(description);
- } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
+ if (Namespace.JINGLE_APPS_RTP.equals(namespace)) {
return RtpDescription.upgrade(description);
} else {
return GenericDescription.upgrade(description);
@@ -73,11 +84,7 @@ public class Content extends Element {
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)) {
+ if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) {
return IceUdpTransportInfo.upgrade(transport);
} else if (transport != null) {
return GenericTransportInfo.upgrade(transport);
@@ -91,7 +98,8 @@ public class Content extends Element {
}
public enum Creator {
- INITIATOR, RESPONDER;
+ INITIATOR,
+ RESPONDER;
public static Creator of(final String value) {
return Creator.valueOf(value.toUpperCase(Locale.ROOT));
@@ -105,16 +113,56 @@ public class Content extends Element {
}
public enum Senders {
- BOTH, INITIATOR, NONE, RESPONDER;
+ BOTH,
+ INITIATOR,
+ NONE,
+ RESPONDER;
public static Senders of(final String value) {
return Senders.valueOf(value.toUpperCase(Locale.ROOT));
}
+ public static Senders of(final SessionDescription.Media media, final boolean initiator) {
+ final Set<String> attributes = media.attributes.keySet();
+ if (attributes.contains("sendrecv")) {
+ return BOTH;
+ } else if (attributes.contains("inactive")) {
+ return NONE;
+ } else if (attributes.contains("sendonly")) {
+ return initiator ? INITIATOR : RESPONDER;
+ } else if (attributes.contains("recvonly")) {
+ return initiator ? RESPONDER : INITIATOR;
+ }
+ Log.w(Config.LOGTAG,"assuming default value for senders");
+ // If none of the attributes "sendonly", "recvonly", "inactive", and "sendrecv" is
+ // present, "sendrecv" SHOULD be assumed as the default
+ // https://www.rfc-editor.org/rfc/rfc4566
+ return BOTH;
+ }
+
@Override
@NonNull
public String toString() {
return super.toString().toLowerCase(Locale.ROOT);
}
+
+ public String asMediaAttribute(final boolean initiator) {
+ final boolean responder = !initiator;
+ if (this == Content.Senders.BOTH) {
+ return "sendrecv";
+ } else if (this == Content.Senders.NONE) {
+ return "inactive";
+ } else if ((initiator && this == Content.Senders.INITIATOR)
+ || (responder && this == Content.Senders.RESPONDER)) {
+ return "sendonly";
+ } else if ((initiator && this == Content.Senders.RESPONDER)
+ || (responder && this == Content.Senders.INITIATOR)) {
+ return "recvonly";
+ } else {
+ throw new IllegalStateException(
+ String.format(
+ "illegal combination of initiator=%s and %s", initiator, this));
+ }
+ }
}
}
@@ -12,8 +12,6 @@ import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
-import java.util.ArrayList;
-import java.util.Collection;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.LinkedHashMap;
@@ -28,23 +26,29 @@ import eu.siacs.conversations.xmpp.jingle.SessionDescription;
public class IceUdpTransportInfo extends GenericTransportInfo {
+ public static final IceUdpTransportInfo STUB = new IceUdpTransportInfo();
+
public IceUdpTransportInfo() {
super("transport", Namespace.JINGLE_TRANSPORT_ICE_UDP);
}
public static IceUdpTransportInfo upgrade(final Element element) {
- Preconditions.checkArgument("transport".equals(element.getName()), "Name of provided element is not transport");
- Preconditions.checkArgument(Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(element.getNamespace()), "Element does not match ice-udp transport namespace");
+ 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) {
+ 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();
+ final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
if (ufrag != null) {
iceUdpTransportInfo.setAttribute("ufrag", ufrag);
}
@@ -56,7 +60,15 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
iceUdpTransportInfo.addChild(fingerprint);
}
return iceUdpTransportInfo;
+ }
+ public static IceUdpTransportInfo of(
+ final Credentials credentials, final Setup setup, final String hash, final String fingerprint) {
+ final IceUdpTransportInfo iceUdpTransportInfo = new IceUdpTransportInfo();
+ iceUdpTransportInfo.addChild(Fingerprint.of(setup, hash, fingerprint));
+ iceUdpTransportInfo.setAttribute("ufrag", credentials.ufrag);
+ iceUdpTransportInfo.setAttribute("pwd", credentials.password);
+ return iceUdpTransportInfo;
}
public Fingerprint getFingerprint() {
@@ -91,7 +103,8 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
transportInfo.setAttribute("ufrag", credentials.ufrag);
transportInfo.setAttribute("pwd", credentials.password);
for (final Element child : getChildren()) {
- if (child.getName().equals("fingerprint") && Namespace.JINGLE_APPS_DTLS.equals(child.getNamespace())) {
+ if (child.getName().equals("fingerprint")
+ && Namespace.JINGLE_APPS_DTLS.equals(child.getNamespace())) {
final Fingerprint fingerprint = new Fingerprint();
fingerprint.setAttributes(new Hashtable<>(child.getAttributes()));
fingerprint.setContent(child.getContent());
@@ -231,7 +244,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
return getAttributeAsInt("rel-port");
}
- public String getType() { //TODO might be converted to enum
+ public String getType() { // TODO might be converted to enum
return getAttribute("type");
}
@@ -256,7 +269,8 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
checkNotNullNoWhitespace(protocol, "protocol");
final String transport = protocol.toLowerCase(Locale.ROOT);
if (!"udp".equals(transport)) {
- throw new IllegalArgumentException(String.format("'%s' is not a supported protocol", transport));
+ throw new IllegalArgumentException(
+ String.format("'%s' is not a supported protocol", transport));
}
final String priority = this.getAttribute("priority");
checkNotNullNoWhitespace(priority, "priority");
@@ -284,7 +298,15 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
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())));
+ 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,
@@ -293,20 +315,19 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
priority,
connectionAddress,
port,
- parametersString
-
- );
+ 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));
+ throw new IllegalArgumentException(
+ String.format("Parameter %s is missing or empty", name));
}
- SessionDescription.checkNoWhitespace(value, String.format("Parameter %s contains white spaces", name));
+ SessionDescription.checkNoWhitespace(
+ value, String.format("Parameter %s contains white spaces", name));
}
-
public static class Fingerprint extends Element {
private Fingerprint() {
@@ -340,11 +361,20 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
return null;
}
- public static Fingerprint of(final SessionDescription sessionDescription, final SessionDescription.Media media) {
+ public static Fingerprint of(
+ final SessionDescription sessionDescription, final SessionDescription.Media media) {
final Fingerprint fingerprint = of(media.attributes);
return fingerprint == null ? of(sessionDescription.attributes) : fingerprint;
}
+ private static Fingerprint of(final Setup setup, final String hash, final String content) {
+ final Fingerprint fingerprint = new Fingerprint();
+ fingerprint.setContent(content);
+ fingerprint.setAttribute("hash", hash);
+ fingerprint.setAttribute("setup", setup.toString().toLowerCase(Locale.ROOT));
+ return fingerprint;
+ }
+
public String getHash() {
return this.getAttribute("hash");
}
@@ -356,7 +386,9 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
}
public enum Setup {
- ACTPASS, PASSIVE, ACTIVE;
+ ACTPASS,
+ PASSIVE,
+ ACTIVE;
public static Setup of(String setup) {
try {
@@ -373,7 +405,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
if (this == ACTIVE) {
return PASSIVE;
}
- throw new IllegalStateException(this.name()+" can not be flipped");
+ throw new IllegalStateException(this.name() + " can not be flipped");
}
}
}
@@ -22,7 +22,6 @@ 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);
@@ -32,6 +31,10 @@ public class RtpDescription extends GenericDescription {
super("description", Namespace.JINGLE_APPS_RTP);
}
+ public static RtpDescription stub(final Media media) {
+ return new RtpDescription(media.toString());
+ }
+
public Media getMedia() {
return Media.of(this.getAttribute("media"));
}
@@ -57,7 +60,8 @@ public class RtpDescription extends GenericDescription {
public List<RtpHeaderExtension> getHeaderExtensions() {
final ImmutableList.Builder<RtpHeaderExtension> builder = new ImmutableList.Builder<>();
for (final Element child : getChildren()) {
- if ("rtp-hdrext".equals(child.getName()) && Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(child.getNamespace())) {
+ if ("rtp-hdrext".equals(child.getName())
+ && Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(child.getNamespace())) {
builder.add(RtpHeaderExtension.upgrade(child));
}
}
@@ -85,8 +89,12 @@ public class RtpDescription extends GenericDescription {
}
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");
+ 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());
@@ -116,7 +124,8 @@ public class RtpDescription extends GenericDescription {
private static FeedbackNegotiation upgrade(final Element element) {
Preconditions.checkArgument("rtcp-fb".equals(element.getName()));
- Preconditions.checkArgument(Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
+ Preconditions.checkArgument(
+ Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
final FeedbackNegotiation feedback = new FeedbackNegotiation();
feedback.setAttributes(element.getAttributes());
feedback.setChildren(element.getChildren());
@@ -126,13 +135,13 @@ public class RtpDescription extends GenericDescription {
public static List<FeedbackNegotiation> fromChildren(final List<Element> children) {
ImmutableList.Builder<FeedbackNegotiation> builder = new ImmutableList.Builder<>();
for (final Element child : children) {
- if ("rtcp-fb".equals(child.getName()) && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
+ 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 {
@@ -142,7 +151,6 @@ public class RtpDescription extends GenericDescription {
this.setAttribute("value", value);
}
-
private FeedbackNegotiationTrrInt() {
super("rtcp-fb-trr-int", Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION);
}
@@ -150,12 +158,12 @@ public class RtpDescription extends GenericDescription {
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()));
+ Preconditions.checkArgument(
+ Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(element.getNamespace()));
final FeedbackNegotiationTrrInt trr = new FeedbackNegotiationTrrInt();
trr.setAttributes(element.getAttributes());
trr.setChildren(element.getChildren());
@@ -163,9 +171,11 @@ public class RtpDescription extends GenericDescription {
}
public static List<FeedbackNegotiationTrrInt> fromChildren(final List<Element> children) {
- ImmutableList.Builder<FeedbackNegotiationTrrInt> builder = new ImmutableList.Builder<>();
+ ImmutableList.Builder<FeedbackNegotiationTrrInt> builder =
+ new ImmutableList.Builder<>();
for (final Element child : children) {
- if ("rtcp-fb-trr-int".equals(child.getName()) && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
+ if ("rtcp-fb-trr-int".equals(child.getName())
+ && Namespace.JINGLE_RTP_FEEDBACK_NEGOTIATION.equals(child.getNamespace())) {
builder.add(upgrade(child));
}
}
@@ -173,9 +183,8 @@ public class RtpDescription extends GenericDescription {
}
}
-
- //XEP-0294: Jingle RTP Header Extensions Negotiation
- //maps to `extmap:$id $uri`
+ // XEP-0294: Jingle RTP Header Extensions Negotiation
+ // maps to `extmap:$id $uri`
public static class RtpHeaderExtension extends Element {
private RtpHeaderExtension() {
@@ -198,7 +207,8 @@ public class RtpDescription extends GenericDescription {
public static RtpHeaderExtension upgrade(final Element element) {
Preconditions.checkArgument("rtp-hdrext".equals(element.getName()));
- Preconditions.checkArgument(Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(element.getNamespace()));
+ Preconditions.checkArgument(
+ Namespace.JINGLE_RTP_HEADER_EXTENSIONS.equals(element.getNamespace()));
final RtpHeaderExtension extension = new RtpHeaderExtension();
extension.setAttributes(element.getAttributes());
extension.setChildren(element.getChildren());
@@ -217,7 +227,7 @@ public class RtpDescription extends GenericDescription {
}
}
- //maps to `rtpmap:$id $name/$clockrate/$channels`
+ // maps to `rtpmap:$id $name/$clockrate/$channels`
public static class PayloadType extends Element {
private PayloadType() {
@@ -238,8 +248,14 @@ public class RtpDescription extends GenericDescription {
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);
+ SessionDescription.checkNoWhitespace(
+ name, "payload-type name must not contain whitespaces");
+ return getId()
+ + " "
+ + name
+ + "/"
+ + getClockRate()
+ + (channels == 1 ? "" : "/" + channels);
}
public int getIntId() {
@@ -251,7 +267,6 @@ public class RtpDescription extends GenericDescription {
return this.getAttribute("id");
}
-
public String getPayloadTypeName() {
return this.getAttribute("name");
}
@@ -271,7 +286,8 @@ public class RtpDescription extends GenericDescription {
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
+ return 1; // The number of channels; if omitted, it MUST be assumed to contain one
+ // channel
}
try {
return Integer.parseInt(channels);
@@ -299,7 +315,9 @@ public class RtpDescription extends GenericDescription {
}
public static PayloadType of(final Element element) {
- Preconditions.checkArgument("payload-type".equals(element.getName()), "element name must be called payload-type");
+ 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());
@@ -331,8 +349,8 @@ public class RtpDescription extends GenericDescription {
}
}
- //map to `fmtp $id key=value;key=value
- //where id is the id of the parent payload-type
+ // 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() {
@@ -354,7 +372,8 @@ public class RtpDescription extends GenericDescription {
}
public static Parameter of(final Element element) {
- Preconditions.checkArgument("parameter".equals(element.getName()), "element name must be called parameter");
+ Preconditions.checkArgument(
+ "parameter".equals(element.getName()), "element name must be called parameter");
Parameter parameter = new Parameter();
parameter.setAttributes(element.getAttributes());
parameter.setChildren(element.getChildren());
@@ -367,12 +386,18 @@ public class RtpDescription extends GenericDescription {
for (int i = 0; i < parameters.size(); ++i) {
final Parameter p = parameters.get(i);
final String name = p.getParameterName();
- Preconditions.checkArgument(name != null, String.format("parameter for %s must have a name", id));
- SessionDescription.checkNoWhitespace(name, String.format("parameter names for %s must not contain whitespaces", id));
+ 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));
+ 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) {
@@ -385,8 +410,11 @@ public class RtpDescription extends GenericDescription {
public static String toSdpString(final String id, final Parameter parameter) {
final String name = parameter.getParameterName();
final String value = parameter.getParameterValue();
- Preconditions.checkArgument(value != null, String.format("parameter for %s must have a value", id));
- SessionDescription.checkNoWhitespace(value, String.format("parameter values for %s must not contain whitespaces", id));
+ Preconditions.checkArgument(
+ value != null, String.format("parameter for %s must have a value", id));
+ SessionDescription.checkNoWhitespace(
+ value,
+ String.format("parameter values for %s must not contain whitespaces", id));
if (Strings.isNullOrEmpty(name)) {
return String.format("%s %s", id, value);
} else {
@@ -412,8 +440,8 @@ public class RtpDescription extends GenericDescription {
}
}
- //XEP-0339: Source-Specific Media Attributes in Jingle
- //maps to `a=ssrc:<ssrc-id> <attribute>:<value>`
+ // XEP-0339: Source-Specific Media Attributes in Jingle
+ // maps to `a=ssrc:<ssrc-id> <attribute>:<value>`
public static class Source extends Element {
private Source() {
@@ -444,7 +472,9 @@ public class RtpDescription extends GenericDescription {
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()));
+ 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());
@@ -481,7 +511,6 @@ public class RtpDescription extends GenericDescription {
return parameter;
}
}
-
}
public static class SourceGroup extends Element {
@@ -517,7 +546,9 @@ public class RtpDescription extends GenericDescription {
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()));
+ 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());
@@ -525,15 +556,18 @@ public class RtpDescription extends GenericDescription {
}
}
- public static RtpDescription of(final SessionDescription sessionDescription, final SessionDescription.Media media) {
+ public static RtpDescription of(
+ final SessionDescription sessionDescription, final SessionDescription.Media media) {
final RtpDescription rtpDescription = new RtpDescription(media.media);
final Map<String, List<Parameter>> parameterMap = new HashMap<>();
- final ArrayListMultimap<String, Element> feedbackNegotiationMap = ArrayListMultimap.create();
- final ArrayListMultimap<String, Source.Parameter> sourceParameterMap = ArrayListMultimap.create();
- final Set<String> attributes = Sets.newHashSet(Iterables.concat(
- sessionDescription.attributes.keySet(),
- media.attributes.keySet()
- ));
+ final ArrayListMultimap<String, Element> feedbackNegotiationMap =
+ ArrayListMultimap.create();
+ final ArrayListMultimap<String, Source.Parameter> sourceParameterMap =
+ ArrayListMultimap.create();
+ final Set<String> attributes =
+ Sets.newHashSet(
+ Iterables.concat(
+ sessionDescription.attributes.keySet(), media.attributes.keySet()));
for (final String rtcpFb : media.attributes.get("rtcp-fb")) {
final String[] parts = rtcpFb.split(" ");
if (parts.length >= 2) {
@@ -542,7 +576,10 @@ public class RtpDescription extends GenericDescription {
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)));
+ feedbackNegotiationMap.put(
+ id,
+ new FeedbackNegotiationTrrInt(
+ SessionDescription.ignorantIntParser(subType)));
}
} else {
feedbackNegotiationMap.put(id, new FeedbackNegotiation(type, subType));
@@ -594,7 +631,8 @@ public class RtpDescription extends GenericDescription {
rtpDescription.addChild(new SourceGroup(semantics, builder.build()));
}
}
- for (Map.Entry<String, Collection<Source.Parameter>> source : sourceParameterMap.asMap().entrySet()) {
+ for (Map.Entry<String, Collection<Source.Parameter>> source :
+ sourceParameterMap.asMap().entrySet()) {
rtpDescription.addChild(new Source(source.getKey(), source.getValue()));
}
if (media.attributes.containsKey("rtcp-mux")) {
@@ -1,10 +1,11 @@
package eu.siacs.conversations.xmpp.stanzas.csi;
+import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
public class ActivePacket extends AbstractStanza {
public ActivePacket() {
super("active");
- setAttribute("xmlns", "urn:xmpp:csi:0");
+ setAttribute("xmlns", Namespace.CSI);
}
}
@@ -1,10 +1,11 @@
package eu.siacs.conversations.xmpp.stanzas.csi;
+import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
public class InactivePacket extends AbstractStanza {
public InactivePacket() {
super("inactive");
- setAttribute("xmlns", "urn:xmpp:csi:0");
+ setAttribute("xmlns", Namespace.CSI);
}
}
@@ -1,12 +1,13 @@
package eu.siacs.conversations.xmpp.stanzas.streammgmt;
+import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
public class AckPacket extends AbstractStanza {
- public AckPacket(int sequence, int smVersion) {
+ public AckPacket(final int sequence) {
super("a");
- this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+ this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
this.setAttribute("h", Integer.toString(sequence));
}
@@ -1,12 +1,13 @@
package eu.siacs.conversations.xmpp.stanzas.streammgmt;
+import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
public class EnablePacket extends AbstractStanza {
- public EnablePacket(int smVersion) {
+ public EnablePacket() {
super("enable");
- this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+ this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
this.setAttribute("resume", "true");
}
@@ -1,12 +1,13 @@
package eu.siacs.conversations.xmpp.stanzas.streammgmt;
+import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
public class RequestPacket extends AbstractStanza {
- public RequestPacket(int smVersion) {
+ public RequestPacket() {
super("r");
- this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+ this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
}
}
@@ -1,12 +1,13 @@
package eu.siacs.conversations.xmpp.stanzas.streammgmt;
+import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.stanzas.AbstractStanza;
public class ResumePacket extends AbstractStanza {
- public ResumePacket(String id, int sequence, int smVersion) {
+ public ResumePacket(final String id, final int sequence) {
super("resume");
- this.setAttribute("xmlns", "urn:xmpp:sm:" + smVersion);
+ this.setAttribute("xmlns", Namespace.STREAM_MANAGEMENT);
this.setAttribute("previd", id);
this.setAttribute("h", Integer.toString(sequence));
}
@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#FFFFFF"
+ android:viewportHeight="24" android:viewportWidth="24"
+ android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
+</vector>
@@ -0,0 +1,5 @@
+<vector android:autoMirrored="true" android:height="24dp"
+ android:tint="#FFFFFF" android:viewportHeight="24"
+ android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="@android:color/white" android:pathData="M19.59,7L12,14.59 6.41,9H11V7H3v8h2v-4.59l7,7 9,-9z"/>
+</vector>
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <item
+ android:id="@+id/action_delete_avatar"
+ android:title="@string/delete_avatar"
+ app:showAsAction="never" />
+</menu>
@@ -17,5 +17,8 @@
android:id="@+id/action_goto_chat"
android:icon="?attr/icon_goto_chat"
android:title="@string/switch_to_conversation"
- app:showAsAction="ifRoom" />
+ app:showAsAction="always" />
+ <item android:id="@+id/action_switch_to_video"
+ android:title="@string/switch_to_video"
+ app:showAsAction="never"/>
</menu>
@@ -239,7 +239,6 @@
<string name="title_pref_enable_quiet_hours">تفعيل ساعات السكون</string>
<string name="pref_quiet_hours_summary">سوف تكتم التنبيهات إبان ساعات السكون</string>
<string name="pref_expert_options_other">أخرى</string>
- <string name="pref_autojoin">زامِن مع الفواصل المرجعية</string>
<string name="conference_banned">حسابك محظور للإلتحاق بمجموعة المحادثة هذه</string>
<string name="conference_members_only">هذه المجموعة متاحة للأعضاء المنتمين إليها فقط</string>
<string name="conference_kicked">تم طردك من مجموعة الدردشة هذه</string>
@@ -294,8 +294,6 @@
<string name="title_pref_enable_quiet_hours">Включване на тихите часове</string>
<string name="pref_quiet_hours_summary">Известията ще бъдат заглушени по време на тихите часове</string>
<string name="pref_expert_options_other">Други</string>
- <string name="pref_autojoin">Синхронизиране с отметките</string>
- <string name="pref_autojoin_summary">Автоматично присъединяване към групови разговори, ако такава е настройката на отметката</string>
<string name="toast_message_omemo_fingerprint">Отпечатъкът OMEMO е копиран</string>
<string name="conference_banned">Достъпът Ви до този групов разговор е забранен</string>
<string name="conference_members_only">Този групов разговор е само за членове</string>
@@ -284,8 +284,6 @@
<string name="title_pref_enable_quiet_hours">Habilitar hores de silenci</string>
<string name="pref_quiet_hours_summary">Les notificacions seràn silenciades a les hores de silenci</string>
<string name="pref_expert_options_other">Altres</string>
- <string name="pref_autojoin">Sincronitzar als marcadors</string>
- <string name="pref_autojoin_summary">Unir-se als xats de grup automàticament si el marcador l\'indica</string>
<string name="toast_message_omemo_fingerprint">Empremta digital de OMEMO copiada en el portapapers</string>
<string name="conference_banned">Estàs prohibit en aquest xat de grup</string>
<string name="conference_members_only">Aquest xat en grup només és de membres</string>
@@ -297,8 +297,6 @@
<string name="title_pref_enable_quiet_hours">Povolit tichý režim</string>
<string name="pref_quiet_hours_summary">Upozornění budou během tichého režimu ztlumena</string>
<string name="pref_expert_options_other">Další</string>
- <string name="pref_autojoin">Synchronizovat se záložkami</string>
- <string name="pref_autojoin_summary">Automaticky se připojovat ke skupinovým chatům, pokud jsou nastaveny v záložkách</string>
<string name="toast_message_omemo_fingerprint">OMEMO otisk zkopírován do schránky</string>
<string name="conference_banned">Byl(a) jste blokován(a) v této skupině</string>
<string name="conference_members_only">Tento skupinový chat je pouze pro registrované členy</string>
@@ -294,8 +294,8 @@
<string name="title_pref_enable_quiet_hours">Aktiver stilletid</string>
<string name="pref_quiet_hours_summary">Notifikationer vil være lydløs under stilletid</string>
<string name="pref_expert_options_other">Andre</string>
- <string name="pref_autojoin">Synkroniser med bogmærker</string>
- <string name="pref_autojoin_summary">Deltag automatisk i gruppechat hvis bogmærket tillader det</string>
+ <string name="pref_autojoin">Synkroniser bogmærker</string>
+ <string name="pref_autojoin_summary">Indstil \"autojoin\"-flag, når du går ind i eller forlader en MUC, og reager på ændringer foretaget af andre klienter.</string>
<string name="toast_message_omemo_fingerprint">OMEMO-fingeraftryk kopieret til udklipsholder</string>
<string name="conference_banned">Du er udelukket fra denne gruppechat</string>
<string name="conference_members_only">Denne gruppechat er kun for medlemmer</string>
@@ -303,6 +303,7 @@
<string name="conference_kicked">Du er blevet smidt ud af denne gruppechat</string>
<string name="conference_shutdown">Gruppechatten er lukket ned</string>
<string name="conference_unknown_error">Du er ikke længere i denne gruppechat</string>
+ <string name="conference_technical_problems">Du forlod denne gruppechat af tekniske årsager</string>
<string name="using_account">anvender konto %s</string>
<string name="hosted_on">hostet på %s</string>
<string name="checking_x">Tjekker %s på HTTP vært</string>
@@ -417,6 +418,7 @@
<string name="video">video</string>
<string name="image">billede</string>
<string name="vector_graphic">vektorgrafik</string>
+ <string name="multimedia_file">multimediefil</string>
<string name="pdf_document">PDF dokument</string>
<string name="apk">Android App</string>
<string name="vcard">Kontakt</string>
@@ -974,4 +976,6 @@
<string name="plain_text_document">Ren tekstdokument</string>
<string name="account_registrations_are_not_supported">Kontoregistrering er ikke understøttet</string>
<string name="no_xmpp_adddress_found">Ingen XMPP-adresse fundet</string>
+ <string name="account_status_temporary_auth_failure">Midlertidig godkendelsesfejl</string>
+ <string name="delete_avatar">Slet avatar</string>
</resources>
@@ -170,6 +170,7 @@
<string name="account_status_tls_error_domain">Domain nicht überprüfbar</string>
<string name="account_status_policy_violation">Verstoß gegen die Richtlinien</string>
<string name="account_status_incompatible_server">Inkompatibler Server</string>
+ <string name="account_status_incompatible_client">Inkompatibler Client</string>
<string name="account_status_stream_error">Stream-Fehler</string>
<string name="account_status_stream_opening_error">Fehler beim Öffnen des Streams</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -292,10 +293,10 @@
<string name="title_pref_quiet_hours_start_time">Beginn</string>
<string name="title_pref_quiet_hours_end_time">Ende</string>
<string name="title_pref_enable_quiet_hours">Ruhige Stunden aktivieren</string>
- <string name="pref_quiet_hours_summary">Benachrichtigungen sind während der ruhigen Stunden stumm.</string>
+ <string name="pref_quiet_hours_summary">Benachrichtigungen sind während der ruhigen Stunden stumm</string>
<string name="pref_expert_options_other">Sonstiges</string>
- <string name="pref_autojoin">Mit Lesezeichen synchronisieren</string>
- <string name="pref_autojoin_summary">Gruppenchats automatisch beitreten, wenn das Lesezeichen dies angibt</string>
+ <string name="pref_autojoin">Lesezeichen synchronisieren</string>
+ <string name="pref_autojoin_summary">Setzt das \"Autojoin\"-Kennzeichen beim Betreten oder Verlassen eines Gruppenchats/Channels und reagiert auf Änderungen durch andere Clients.</string>
<string name="toast_message_omemo_fingerprint">OMEMO-Fingerabdruck in die Zwischenablage kopiert</string>
<string name="conference_banned">Du wurdest aus diesem Gruppenchat ausgeschlossen</string>
<string name="conference_members_only">Dieser Gruppenchat ist nur für Mitglieder</string>
@@ -303,6 +304,7 @@
<string name="conference_kicked">Du wurdest aus diesem Gruppchat geworfen</string>
<string name="conference_shutdown">Gruppenchat wurde geschlossen</string>
<string name="conference_unknown_error">Du bist nicht länger in diesem Gruppenchat</string>
+ <string name="conference_technical_problems">Du hast diesen Gruppenchat aus technischen Gründen verlassen</string>
<string name="using_account">verwende Konto %s</string>
<string name="hosted_on">gehostet bei %s</string>
<string name="checking_x">%s auf HTTP-Host wird überprüft</string>
@@ -362,7 +364,7 @@
<string name="clear_other_devices">Geräte entfernen</string>
<string name="clear_other_devices_desc">Bist du sicher, dass du alle anderen Geräte aus der OMEMO-Bekanntmachung entfernen willst? Die Bekanntmachung wird bei der nächsten Verbindung erneuert aber möglicherweise werden keine zwischenzeitlich gesendeten Nachrichten empfangen.</string>
<string name="error_no_keys_to_trust_server_error">Für diesen Kontakt sind keine nutzbaren Schlüssel verfügbar.\nEs konnten keine neuen Schlüssel vom Server abgerufen werden. Gibt es vielleicht ein Problem mit dem Server deines Kontaktes?</string>
- <string name="error_no_keys_to_trust_presence">Für diesen Kontakt sind keine benutzbaren Schlüssel verfügbar.\nStelle sicher, dass ihre beide gegenseitig den Online-Status aktiviert habt.</string>
+ <string name="error_no_keys_to_trust_presence">Für diesen Kontakt sind keine benutzbaren Schlüssel verfügbar.\nStelle sicher, dass ihr beide gegenseitig den Online-Status aktiviert habt.</string>
<string name="error_trustkeys_title">Etwas ist schief gelaufen</string>
<string name="fetching_history_from_server">Lade Chatverlauf vom Server</string>
<string name="no_more_history_on_server">Keine weiteren Nachrichten vorhanden</string>
@@ -473,7 +475,7 @@
<string name="server_info_broken">Fehlerhaft</string>
<string name="pref_presence_settings">Status</string>
<string name="pref_away_when_screen_off">Abwesend bei gesperrtem Gerät</string>
- <string name="pref_away_when_screen_off_summary">Als abwesend anzeigen, wenn das Gerät gesperrt ist.</string>
+ <string name="pref_away_when_screen_off_summary">Als abwesend anzeigen, wenn das Gerät gesperrt ist</string>
<string name="pref_dnd_on_silent_mode">Beschäftigt im lautlosen Modus</string>
<string name="pref_dnd_on_silent_mode_summary">Als Beschäftigt anzeigen, wenn sich das Gerät im lautlosen Modus befindet</string>
<string name="pref_treat_vibrate_as_silent">Vibration als Lautlos behandeln</string>
@@ -556,7 +558,7 @@
<string name="presence_xa">Nicht verfügbar</string>
<string name="presence_dnd">Beschäftigt</string>
<string name="secure_password_generated">Ein sicheres Passwort wurde erstellt</string>
- <string name="device_does_not_support_battery_op">Dein Gerät unterstützt kein Ausschalten der Akkuoptimierung</string>
+ <string name="device_does_not_support_battery_op">Dein Gerät unterstützt nicht das Ausschalten der Akkuoptimierung</string>
<string name="registration_please_wait">Registrierung fehlgeschlagen: Bitte später versuchen</string>
<string name="registration_password_too_weak">Registrierung fehlgeschlagen: Passwort zu schwach</string>
<string name="choose_participants">Teilnehmer wählen</string>
@@ -764,6 +766,7 @@
<string name="messages_channel_name">Nachrichten</string>
<string name="incoming_calls_channel_name">Eingehende Anrufe</string>
<string name="ongoing_calls_channel_name">Laufende Anrufe</string>
+ <string name="missed_calls_channel_name">Entgangene Anrufe</string>
<string name="silent_messages_channel_name">Lautlose Nachrichten</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Fehlgeschlagene Zustellungen</string>
@@ -904,6 +907,8 @@
<string name="make_call">Anrufen</string>
<string name="rtp_state_incoming_call">Eingehender Anruf</string>
<string name="rtp_state_incoming_video_call">Eingehender Videoanruf</string>
+ <string name="rtp_state_content_add_video">Umschalten auf Videoanruf?</string>
+ <string name="rtp_state_content_add">Zusätzliche Audiospuren hinzufügen?</string>
<string name="rtp_state_connecting">Verbinden</string>
<string name="rtp_state_connected">Verbunden</string>
<string name="rtp_state_reconnecting">Erneut verbinden</string>
@@ -931,6 +936,18 @@
<string name="outgoing_call">Ausgehender Anruf</string>
<string name="outgoing_call_duration">Ausgehender Anruf · %s</string>
<string name="missed_call">Entgangener Anruf</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d entgangener Anruf von %2$s</item>
+ <item quantity="other">%1$d entgangene Anrufe von %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d entgangener Anruf</item>
+ <item quantity="other">%d entgangene Anrufe</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d entgangener Anruf von %2$d Kontakt</item>
+ <item quantity="other">%1$d entgangene Anrufe von %2$d Kontakten</item>
+ </plurals>
<string name="audio_call">Audioanruf</string>
<string name="video_call">Videoanruf</string>
<string name="help">Hilfe</string>
@@ -976,5 +993,9 @@
<string name="account_registrations_are_not_supported">Kontoregistrierungen werden nicht unterstützt</string>
<string name="no_xmpp_adddress_found">Keine XMPP-Adresse gefunden</string>
<string name="account_status_temporary_auth_failure">Temporärer Authentifizierungsfehler</string>
+ <string name="delete_avatar">Profilbild löschen</string>
+ <string name="audio_video_disabled_tor">Anrufe sind bei der Verwendung von Tor deaktiviert</string>
+ <string name="switch_to_video">Umschalten auf Video</string>
+ <string name="reject_switch_to_video">Umschalten auf Video ablehnen</string>
</resources>
@@ -294,8 +294,6 @@
<string name="title_pref_enable_quiet_hours">Ενεργοποίηση ωρών ησυχίας</string>
<string name="pref_quiet_hours_summary">Οι ειδοποιήσεις θα σιγαστούν κατά τις ώρες ησυχίας</string>
<string name="pref_expert_options_other">Άλλο</string>
- <string name="pref_autojoin">Συγχρονισμός με σελιδοδείκτες</string>
- <string name="pref_autojoin_summary">Συμμετοχή σε ομαδικές συζητήσεις αυτόματα αν ο σελιδοδείκτης αναφέρει αυτόματη συμμετοχή</string>
<string name="toast_message_omemo_fingerprint">Το αποτύπωμα OMEMO αντιγράφηκε στο πρόχειρο</string>
<string name="conference_banned">Είστε αποκλεισμένοι από αυτή την ομαδική συζήτηση</string>
<string name="conference_members_only">Αυτή η ομαδική συζήτηση είναι μόνο για μέλη</string>
@@ -173,6 +173,7 @@
<string name="account_status_tls_error_domain">Dominio no verificable</string>
<string name="account_status_policy_violation">Policy violation</string>
<string name="account_status_incompatible_server">Servidor incompatible</string>
+ <string name="account_status_incompatible_client">Cliente incompatible</string>
<string name="account_status_stream_error">Error de flujo</string>
<string name="account_status_stream_opening_error">Error al abrir la secuencia</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -298,7 +299,7 @@
<string name="pref_quiet_hours_summary">Las notificaciones serán silenciadas durante el horario de silencio</string>
<string name="pref_expert_options_other">Otros</string>
<string name="pref_autojoin">Sincronizar marcadores</string>
- <string name="pref_autojoin_summary">Unirse a conversaciones en grupo automáticamente si el marcador así lo indica</string>
+ <string name="pref_autojoin_summary">Establecer la opción \"unirse automáticamente\" cuando entras o sales de un MUC y reaccionar a las modificaciones realizadas por otros clientes.</string>
<string name="toast_message_omemo_fingerprint">Huella digital OMEMO copiada al portapapeles</string>
<string name="conference_banned">Tu entrada a esta conversación en grupo ha sido prohibida</string>
<string name="conference_members_only">Esta conversación en grupo es solo para miembros</string>
@@ -306,6 +307,7 @@
<string name="conference_kicked">Has sido expulsado de esta conversación</string>
<string name="conference_shutdown">La conversación en grupo ha sido cerrada</string>
<string name="conference_unknown_error">Ya no estás dentro de esta conversación en grupo</string>
+ <string name="conference_technical_problems">Has dejado esta conversación en grupo debido a razones técnicas.</string>
<string name="using_account">Usando cuenta %s</string>
<string name="hosted_on">alojado en %s</string>
<string name="checking_x">Comprobando %s en servidor HTTP</string>
@@ -420,6 +422,7 @@
<string name="video">vídeo</string>
<string name="image">imagen</string>
<string name="vector_graphic">gráfico de vectores</string>
+ <string name="multimedia_file">archivo multimedia</string>
<string name="pdf_document">documento PDF</string>
<string name="apk">Android App</string>
<string name="vcard">Contacto</string>
@@ -774,6 +777,7 @@
<string name="messages_channel_name">Mensajes</string>
<string name="incoming_calls_channel_name">Llamadas entrantes</string>
<string name="ongoing_calls_channel_name">Llamadas salientes</string>
+ <string name="missed_calls_channel_name">Llamadas perdidas</string>
<string name="silent_messages_channel_name">Mensajes sin sonido</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Envíos fallidos</string>
@@ -941,6 +945,21 @@
<string name="outgoing_call">Llamada saliente</string>
<string name="outgoing_call_duration">Video llamada saliente · %s</string>
<string name="missed_call">Llamada perdida</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d llamada perdida de %2$s</item>
+ <item quantity="many">%1$d llamadas perdidas de %2$s</item>
+ <item quantity="other">%1$d llamadas perdidas de %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d llamada perdida</item>
+ <item quantity="many">%d llamadas perdidas</item>
+ <item quantity="other">%d llamadas perdidas</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d llamadas perdidas de %2$d contacto</item>
+ <item quantity="many">%1$d llamadas perdidas de %2$d contacto</item>
+ <item quantity="other">%1$d llamadas perdidas de %2$d contactos</item>
+ </plurals>
<string name="audio_call">Audio llamada</string>
<string name="video_call">Video llamada</string>
<string name="help">Ayuda</string>
@@ -988,5 +1007,6 @@
<string name="account_registrations_are_not_supported">Los registros de cuenta no están soportados</string>
<string name="no_xmpp_adddress_found">Dirección XMPP no encontrada</string>
<string name="account_status_temporary_auth_failure">Fallo temporal de autenticación</string>
-
-</resources>
+ <string name="delete_avatar">Eliminar imagen de perfil</string>
+ <string name="audio_video_disabled_tor">Las llamadas están deshabilitadas cuando se usa Tor</string>
+ </resources>
@@ -239,7 +239,6 @@
<string name="title_pref_enable_quiet_hours">Ordu lasaiak gaitu</string>
<string name="pref_quiet_hours_summary">Jakinarazpenak isilaraziko dira ordu lasaiak iraun bitartean </string>
<string name="pref_expert_options_other">Besteak</string>
- <string name="pref_autojoin">Laster-markekin sinkronizatu</string>
<string name="conference_banned">Talde honetara sartzea debekatuta duzu</string>
<string name="conference_members_only">Talde hau kideentzat da soilik</string>
<string name="conference_resource_constraint">Baliabide murrizketa</string>
@@ -289,8 +289,6 @@
<string name="title_pref_enable_quiet_hours">Ota käyttöön hiljaisuus</string>
<string name="pref_quiet_hours_summary">Ilmoitukset vaimennetaan hiljaisuuden aikana</string>
<string name="pref_expert_options_other">Muut</string>
- <string name="pref_autojoin">Synkronoi kirjanmerkkien kanssa</string>
- <string name="pref_autojoin_summary">Liity ryhmään automaattisesti jos se on kirjanmerkeissäsi</string>
<string name="toast_message_omemo_fingerprint">OMEMO-sormenjälki kopioitu leikepöydälle</string>
<string name="conference_banned">Sinut on estetty tästä ryhmäkeskustelusta</string>
<string name="conference_members_only">Tämä ryhmäkeskustelu on vain jäsenille</string>
@@ -295,8 +295,6 @@
<string name="title_pref_enable_quiet_hours">Activer les heures tranquilles</string>
<string name="pref_quiet_hours_summary">Les notifications seront muettes pendant les heures tranquilles.</string>
<string name="pref_expert_options_other">Autres</string>
- <string name="pref_autojoin">Synchroniser avec les signets</string>
- <string name="pref_autojoin_summary">Rejoindre automatiquement les groupes marqués en favoris</string>
<string name="toast_message_omemo_fingerprint">Empreinte OMEMO copiée dans le presse-papier</string>
<string name="conference_banned">Vous êtes bannis de ce groupe</string>
<string name="conference_members_only">Ce groupe est réservé aux membres</string>
@@ -170,6 +170,7 @@
<string name="account_status_tls_error_domain">Dominio non verificable</string>
<string name="account_status_policy_violation">Violación da política</string>
<string name="account_status_incompatible_server">Servidor incompatible</string>
+ <string name="account_status_incompatible_client">Cliente non compatible</string>
<string name="account_status_stream_error">Erro de fluxo</string>
<string name="account_status_stream_opening_error">Fallo ao abrir o fluxo</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -294,8 +295,8 @@
<string name="title_pref_enable_quiet_hours">Establecer horario sen notificacións</string>
<string name="pref_quiet_hours_summary">As notificacións serán silenciadas durante estas horas</string>
<string name="pref_expert_options_other">Outro</string>
- <string name="pref_autojoin">Sincronizar cos marcadores</string>
- <string name="pref_autojoin_summary">Unirte as conversas en grupo automáticamente se o marcador así o indica</string>
+ <string name="pref_autojoin">Sincronizar marcadores</string>
+ <string name="pref_autojoin_summary">Poñer marca de \"autojoin\" ao entrar ou deixar unha MUC e reaccionar ás modificacións feitas desde outros clientes.</string>
<string name="toast_message_omemo_fingerprint">Copiouse a impresión dixital OMEMO ao portapapeis</string>
<string name="conference_banned">Non podes acceder a esta conversa en grupo</string>
<string name="conference_members_only">Esta conversa en grupo é so para membros</string>
@@ -303,6 +304,7 @@
<string name="conference_kicked">Xa foi expulsado de esta conversa en grupo</string>
<string name="conference_shutdown">A conversa en grupo foi apagada</string>
<string name="conference_unknown_error">Xa non estás nesta conversa en grupo</string>
+ <string name="conference_technical_problems">Deixaches esta conversa en grupo por razóns técnicas</string>
<string name="using_account">utilizando a conta %s</string>
<string name="hosted_on">hospedado en %s</string>
<string name="checking_x">Comprobando %s no servidor HTTP</string>
@@ -764,6 +766,7 @@
<string name="messages_channel_name">Mensaxes</string>
<string name="incoming_calls_channel_name">Chamadas recibidas</string>
<string name="ongoing_calls_channel_name">Chamadas realizadas</string>
+ <string name="missed_calls_channel_name">Chamadas perdidas</string>
<string name="silent_messages_channel_name">Mensaxes acalados</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Entregas fallidas</string>
@@ -904,6 +907,8 @@
<string name="make_call">Facer unha chamada</string>
<string name="rtp_state_incoming_call">Chamada entrante</string>
<string name="rtp_state_incoming_video_call">Videochamada entrante</string>
+ <string name="rtp_state_content_add_video">Cambiar a unha chamada de vídeo?</string>
+ <string name="rtp_state_content_add">Engadir pistas adicionais?</string>
<string name="rtp_state_connecting">Conectando</string>
<string name="rtp_state_connected">Conectado</string>
<string name="rtp_state_reconnecting">Reconectando</string>
@@ -931,6 +936,18 @@
<string name="outgoing_call">Chamada realizada</string>
<string name="outgoing_call_duration">Conversa de · %s</string>
<string name="missed_call">Chamada perdida</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d chamada perdida de %2$s</item>
+ <item quantity="other">%1$d chamadas perdidas de %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d chamada perdida</item>
+ <item quantity="other">%d chamadas perdidas</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d chamadas perdidas de %2$d contacto</item>
+ <item quantity="other">%1$d chamadas perdidas de %2$d contactos</item>
+ </plurals>
<string name="audio_call">Chamada de audio</string>
<string name="video_call">Chamada de vídeo</string>
<string name="help">Axuda</string>
@@ -976,5 +993,9 @@
<string name="account_registrations_are_not_supported">Non está permitido o rexistro de novas contas</string>
<string name="no_xmpp_adddress_found">Non se atopa un enderezo XMPP</string>
<string name="account_status_temporary_auth_failure">Fallo temporal da autenticación</string>
+ <string name="delete_avatar">Eliminar avatar</string>
+ <string name="audio_video_disabled_tor">As chamadas están desactivadas cando usas Tor</string>
+ <string name="switch_to_video">Cambiar a vídeo</string>
+ <string name="reject_switch_to_video">Rexeitar a solicitude para cambiar a vídeo</string>
</resources>
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="action_settings">Postavke</string>
+ <string name="action_add">Novi razgovor</string>
+ <string name="action_accounts">Upravljanje računima</string>
+ <string name="action_account">Upravljaj računom</string>
+ <string name="action_end_conversation">Zatvori razgovor</string>
+ <string name="action_contact_details">Kontakt podaci</string>
+ <string name="action_muc_details">Pojedinosti grupnog razgovora</string>
+ <string name="channel_details">Detalji kanala</string>
+ <string name="action_add_account">Dodaj račun</string>
+ <string name="action_edit_contact">Uredi ime</string>
+ <string name="action_add_phone_book">Dodaj u adresar</string>
+ <string name="action_delete_contact">Izbriši s popisa</string>
+ <string name="action_block_contact">Blokiraj kontakt</string>
+ <string name="action_unblock_contact">Odblokiraj kontakt</string>
+ <string name="action_block_domain">Blokiraj domenu</string>
+ <string name="action_unblock_domain">Odblokiraj domenu</string>
+ <string name="action_block_participant">Blokiraj sudionika</string>
+ <string name="action_unblock_participant">Deblokiraj sudionika</string>
+ <string name="title_activity_manage_accounts">Upravljanje računima</string>
+ <string name="title_activity_settings">Postavke</string>
+ <string name="title_activity_sharewith">Dijeli s Conversation</string>
+ <string name="title_activity_start_conversation">Započni razgovor</string>
+ <string name="title_activity_choose_contact">Odaberite Kontakt</string>
+ <string name="title_activity_choose_contacts">Odaberite kontakte</string>
+ <string name="title_activity_share_via_account">Dijeli putem računa</string>
+ <string name="title_activity_block_list">Lista blokiranih</string>
+ <string name="just_now">upravo sad</string>
+ <string name="minute_ago">prije 1 min</string>
+ <string name="minutes_ago">prije %d min</string>
+ <plurals name="x_unread_conversations">
+ <item quantity="one">%d nepročitan razgovor</item>
+
+
+ <item quantity="few">%d nepročitanih razgovora</item>
+
+
+ <item quantity="other">%d nepročitani razgovori</item>
+
+ </plurals>
+ <string name="sending">slanje…</string>
+ <string name="message_decrypting">Dešifriranje poruke. Molimo pričekajte…</string>
+ <string name="pgp_message">OpenPGP šifrirana poruka</string>
+ <string name="nick_in_use">Nadimak je već u upotrebi</string>
+ <string name="invalid_muc_nick">Nevažeći nadimak</string>
+ <string name="admin">Admin</string>
+ <string name="owner">Vlasnik</string>
+ <string name="moderator">Moderator</string>
+ <string name="participant">Sudionik</string>
+ <string name="visitor">Posjetitelj</string>
+ <string name="remove_contact_text">Želite li ukloniti %s s popisa kontakata? Razgovori s ovim kontaktom neće biti uklonjeni.</string>
+ <string name="block_contact_text">Želite li blokirati %s da vam šalje poruke?</string>
+ <string name="unblock_contact_text">Želite li deblokirati %s i dopustiti im da vam šalju poruke?</string>
+ <string name="block_domain_text">Blokirati sve kontakte iz %s?</string>
+ <string name="unblock_domain_text">Deblokirati sve kontakte iz %s?</string>
+ <string name="contact_blocked">Kontakt blokiran</string>
+ <string name="blocked">Blokiran</string>
+ <string name="remove_bookmark_text">Želite li ukloniti %s kao oznaku? Razgovori s ovom knjižnom oznakom neće biti uklonjeni.</string>
+ <string name="register_account">Registrirajte novi račun na poslužitelju</string>
+ <string name="change_password_on_server">Promjena lozinke na poslužitelju</string>
+ <string name="share_with">Podijeli s…</string>
+ <string name="start_conversation">Započni razgovor</string>
+ <string name="invite_contact">Pozovi kontakt</string>
+ <string name="invite">Pozovi</string>
+ <string name="contacts">Kontakti</string>
+ <string name="contact">Kontakt</string>
+ <string name="cancel">Otkazati</string>
+ <string name="add">Dodati</string>
+ <string name="edit">Uredi</string>
+ <string name="delete">Obriši</string>
+ <string name="block">Blok</string>
+ <string name="unblock">Odblokiraj</string>
+ <string name="save">Sačuvaj</string>
+ <string name="ok">Ok</string>
+ </resources>
@@ -289,8 +289,6 @@
<string name="title_pref_enable_quiet_hours">Csendes órák engedélyezése</string>
<string name="pref_quiet_hours_summary">Az értesítések el lesznek némítva a csendes órák alatt</string>
<string name="pref_expert_options_other">Egyéb</string>
- <string name="pref_autojoin">Szinkronizálás a könyvjelzőkkel</string>
- <string name="pref_autojoin_summary">Automatikusan csatlakozzon a csoportos csevegésekhez, ha ez szerepel a könyvjelzőben</string>
<string name="toast_message_omemo_fingerprint">OMEMO ujjlenyomat a vágólapra lett másolva</string>
<string name="conference_banned">Ki van tiltva ebből a csoportos csevegésből</string>
<string name="conference_members_only">Ez a csoportos csevegés csak tagoknak szól</string>
@@ -173,6 +173,7 @@
<string name="account_status_tls_error_domain">Dominio non verificabile</string>
<string name="account_status_policy_violation">Violazione della policy</string>
<string name="account_status_incompatible_server">Server non compatibile</string>
+ <string name="account_status_incompatible_client">Client non compatibile</string>
<string name="account_status_stream_error">Errore di stream</string>
<string name="account_status_stream_opening_error">Errore apertura flusso</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -297,8 +298,8 @@
<string name="title_pref_enable_quiet_hours">Attiva ore di quiete</string>
<string name="pref_quiet_hours_summary">Le notifiche verranno silenziate durante le ore di quiete</string>
<string name="pref_expert_options_other">Altro</string>
- <string name="pref_autojoin">Sincronizza con i segnalibri</string>
- <string name="pref_autojoin_summary">Entra nelle chat di gruppo automaticamente se il segnalibro dice così</string>
+ <string name="pref_autojoin">Sincronizza i segnalibri</string>
+ <string name="pref_autojoin_summary">Imposta il flag \"auto-entrata\" quando entri o esci da un MUC e reagisci alle modifiche fatte dagli altri client.</string>
<string name="toast_message_omemo_fingerprint">Impronta OMEMO copiata negli appunti</string>
<string name="conference_banned">Sei stato bandito da questa chat di gruppo</string>
<string name="conference_members_only">Questa chat di gruppo è solo per membri</string>
@@ -306,6 +307,7 @@
<string name="conference_kicked">Sei stato buttato fuori da questa chat di gruppo</string>
<string name="conference_shutdown">La chat di gruppo è stata chiusa</string>
<string name="conference_unknown_error">Non sei più in questa chat di gruppo</string>
+ <string name="conference_technical_problems">Hai lasciato questa chat di gruppo per motivi tecnici</string>
<string name="using_account">usando il profilo %s</string>
<string name="hosted_on">ospitato su %s</string>
<string name="checking_x">Controllo %s su host HTTP</string>
@@ -420,6 +422,7 @@
<string name="video">video</string>
<string name="image">immagine</string>
<string name="vector_graphic">grafica vettoriale</string>
+ <string name="multimedia_file">file multimediale</string>
<string name="pdf_document">Documento PDF</string>
<string name="apk">Applicazione Android</string>
<string name="vcard">Contatto</string>
@@ -774,6 +777,7 @@
<string name="messages_channel_name">Messaggi</string>
<string name="incoming_calls_channel_name">Chiamate in arrivo</string>
<string name="ongoing_calls_channel_name">Chiamate in uscita</string>
+ <string name="missed_calls_channel_name">Chiamate perse</string>
<string name="silent_messages_channel_name">Messaggi silenziosi</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Recapiti falliti</string>
@@ -914,6 +918,8 @@
<string name="make_call">Chiama</string>
<string name="rtp_state_incoming_call">Chiamata in arrivo</string>
<string name="rtp_state_incoming_video_call">Chiamata video in arrivo</string>
+ <string name="rtp_state_content_add_video">Passare a una videochiamata?</string>
+ <string name="rtp_state_content_add">Aggiungere altre tracce?</string>
<string name="rtp_state_connecting">Connessione</string>
<string name="rtp_state_connected">Connesso</string>
<string name="rtp_state_reconnecting">Riconnessione</string>
@@ -941,6 +947,21 @@
<string name="outgoing_call">Chiamata in uscita</string>
<string name="outgoing_call_duration">Chiamata in uscita · %s</string>
<string name="missed_call">Chiamata persa</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d chiamata persa da %2$s</item>
+ <item quantity="many">%1$d chiamate perse da %2$s</item>
+ <item quantity="other">%1$d chiamate perse da %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d chiamata persa</item>
+ <item quantity="many">%d chiamate perse</item>
+ <item quantity="other">%d chiamate perse</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d chiamate perse da %2$d contatto</item>
+ <item quantity="many">%1$d chiamate perse da %2$d contatti</item>
+ <item quantity="other">%1$d chiamate perse da %2$d contatti</item>
+ </plurals>
<string name="audio_call">Chiamata vocale</string>
<string name="video_call">Chiamata video</string>
<string name="help">Aiuto</string>
@@ -988,5 +1009,9 @@
<string name="account_registrations_are_not_supported">Le registrazioni di profili non sono supportate</string>
<string name="no_xmpp_adddress_found">Nessun indirizzo XMPP trovato</string>
<string name="account_status_temporary_auth_failure">Errore di autenticazione temporaneo</string>
+ <string name="delete_avatar">Elimina avatar</string>
+ <string name="audio_video_disabled_tor">Le chiamate sono disattivate quando si usa Tor</string>
+ <string name="switch_to_video">Passa al video</string>
+ <string name="reject_switch_to_video">Rifiuta richiesta di passare al video</string>
</resources>
@@ -291,8 +291,7 @@
<string name="title_pref_enable_quiet_hours">消音時間を有効化</string>
<string name="pref_quiet_hours_summary">消音時間の間、通知は無音になります</string>
<string name="pref_expert_options_other">その他</string>
- <string name="pref_autojoin">ブックマークと同期</string>
- <string name="pref_autojoin_summary">ブックマークに従って、グループチャットに自動で参加します。</string>
+ <string name="pref_autojoin">ブックマーク同期</string>
<string name="toast_message_omemo_fingerprint">OMEMO フィンガープリントをクリップボードにコピーしました</string>
<string name="conference_banned">このグループチャットから出禁にされています</string>
<string name="conference_members_only">このグループチャットはメンバー制です</string>
@@ -300,6 +299,7 @@
<string name="conference_kicked">このグループチャットから蹴り出されています</string>
<string name="conference_shutdown">このグループチャットは閉鎖されました</string>
<string name="conference_unknown_error">あなたはもうこのグループチャットに参加していません</string>
+ <string name="conference_technical_problems">技術的理由の為、あなたはこのグループチャットを離れました</string>
<string name="using_account">アカウント %s を使用</string>
<string name="hosted_on">%s 上でホストされた</string>
<string name="checking_x">HTTP ホスト上の %s を確認中</string>
@@ -414,6 +414,7 @@
<string name="video">ビデオ</string>
<string name="image">画像</string>
<string name="vector_graphic">ベクター画像</string>
+ <string name="multimedia_file">マルチメディアファイル</string>
<string name="pdf_document">PDF 文書</string>
<string name="apk">Android アプリ</string>
<string name="vcard">連絡先</string>
@@ -956,5 +957,5 @@
<string name="account_registrations_are_not_supported">アカウント登録はサポートされていません</string>
<string name="no_xmpp_adddress_found">XMPPアドレスがみつかりません</string>
<string name="account_status_temporary_auth_failure">一時的な認証失敗</string>
-
-</resources>
+ <string name="delete_avatar">アバターを削除</string>
+ </resources>
@@ -276,7 +276,6 @@
<string name="title_pref_enable_quiet_hours">Stille uren inschakelen</string>
<string name="pref_quiet_hours_summary">Tijdens stille uren worden meldingen onderdrukt</string>
<string name="pref_expert_options_other">Andere</string>
- <string name="pref_autojoin">Synchroniseren met bladwijzers</string>
<string name="toast_message_omemo_fingerprint">OMEMO-vingerafdruk gekopieerd naar klembord</string>
<string name="conference_banned">Je bent verbannen uit dit groepsgesprek</string>
<string name="conference_members_only">Dit groepsgesprek is enkel voor leden</string>
@@ -176,6 +176,7 @@
<string name="account_status_tls_error_domain">Nie można zweryfikować tej domeny</string>
<string name="account_status_policy_violation">Naruszenie zasad</string>
<string name="account_status_incompatible_server">Serwer niekompatybilny</string>
+ <string name="account_status_incompatible_client">Niekompatybilny klient</string>
<string name="account_status_stream_error">Błąd strumienia</string>
<string name="account_status_stream_opening_error">Błąd otwierania strumienia</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -300,8 +301,8 @@
<string name="title_pref_enable_quiet_hours">Włącz godziny ciszy</string>
<string name="pref_quiet_hours_summary">Powiadomienia będą wyciszone w wybranym przedziale czasu</string>
<string name="pref_expert_options_other">Inne</string>
- <string name="pref_autojoin">Synchronizuj z zakładkami</string>
- <string name="pref_autojoin_summary">Dołączaj do rozmów grupowych automatycznie jeśli na to wskazuje zakładka</string>
+ <string name="pref_autojoin">Synchronizuj zakładki</string>
+ <string name="pref_autojoin_summary">Ustaw flagę automatycznego dołączania przy wchodzeniu lub opuszczaniu pokoju i reaguj na zmiany innych klientów</string>
<string name="toast_message_omemo_fingerprint">Odcisk klucza OMEMO został skopiowany do schowka</string>
<string name="conference_banned">Zbanowany</string>
<string name="conference_members_only">Konferencja tylko dla użytkowników</string>
@@ -309,6 +310,7 @@
<string name="conference_kicked">Wykopany</string>
<string name="conference_shutdown">Konferencja została zamknięta</string>
<string name="conference_unknown_error">Nie uczestniczysz już w tej konferencji</string>
+ <string name="conference_technical_problems">Opuszczono rozmowę grupową z powodu usterki technicznej</string>
<string name="using_account">używając konta %s</string>
<string name="hosted_on">udostępnione na %s</string>
<string name="checking_x">Sprawdzanie %s na hoście HTTP</string>
@@ -787,6 +789,7 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
<string name="messages_channel_name">Wiadomości</string>
<string name="incoming_calls_channel_name">Połączenia przychodzące</string>
<string name="ongoing_calls_channel_name">Połączenia wychodzące</string>
+ <string name="missed_calls_channel_name">Nieodebrane rozmowy</string>
<string name="silent_messages_channel_name">Ciche wiadomości</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Nie dostarczone wiadomości</string>
@@ -927,6 +930,8 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
<string name="make_call">Zadzwoń</string>
<string name="rtp_state_incoming_call">Połączenie przychodzące</string>
<string name="rtp_state_incoming_video_call">Wideorozmowa przychodząca</string>
+ <string name="rtp_state_content_add_video">Przełączyć na rozmowę wideo?</string>
+ <string name="rtp_state_content_add">Włączyć dodatkowe ścieżki?</string>
<string name="rtp_state_connecting">Łączenie</string>
<string name="rtp_state_connected">Połączony</string>
<string name="rtp_state_reconnecting">Ponowne łączenie</string>
@@ -954,6 +959,24 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
<string name="outgoing_call">Połączenie wychodzące</string>
<string name="outgoing_call_duration">Połączenie wychodzące · %s</string>
<string name="missed_call">Nieodebrane połączenie</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d nieodebrana rozmowa od %2$s</item>
+ <item quantity="few">%1$d nieodebrane rozmowy od %2$s</item>
+ <item quantity="many">%1$d nieodebranych rozmów od %2$s</item>
+ <item quantity="other">%1$d nieodebranych rozmów od %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d nieodebrana rozmowa</item>
+ <item quantity="few">%d nieodebrane rozmowy</item>
+ <item quantity="many">%d nieodebranych rozmów</item>
+ <item quantity="other">%d nieodebranych rozmów</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d nieodebrana rozmowa od %2$d kontaktu</item>
+ <item quantity="few">%1$d nieodebrane rozmowy od %2$d kontaktu</item>
+ <item quantity="many">%1$d nieodebranych rozmów od %2$d kontaktów</item>
+ <item quantity="other">%1$d nieodebranych rozmów od %2$d kontaktów</item>
+ </plurals>
<string name="audio_call">Połączenie audio</string>
<string name="video_call">Połączenie wideo</string>
<string name="help">Pomoc</string>
@@ -1003,5 +1026,9 @@ Administrator twojego serwera będzie mógł czytać twoje wiadomości, ale moż
<string name="account_registrations_are_not_supported">Rejestracja kont nie jest wspierana</string>
<string name="no_xmpp_adddress_found">Nie znaleziono adresu XMPP</string>
<string name="account_status_temporary_auth_failure">Tymczasowy błąd uwierzytelniania</string>
+ <string name="delete_avatar">Usuń awatar</string>
+ <string name="audio_video_disabled_tor">Dzwonienie jest wyłączone podczas używania Tora</string>
+ <string name="switch_to_video">Przełącz na wideo</string>
+ <string name="reject_switch_to_video">Odrzuć prośbę przełączenia na wideo</string>
</resources>
@@ -173,6 +173,7 @@
<string name="account_status_tls_error_domain">Domínio não verificável</string>
<string name="account_status_policy_violation">Violação de política</string>
<string name="account_status_incompatible_server">Servidor incompatível</string>
+ <string name="account_status_incompatible_client">Cliente incompatível</string>
<string name="account_status_stream_error">Erro de fluxo</string>
<string name="account_status_stream_opening_error">Erro na abertura do fluxo</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -297,8 +298,8 @@
<string name="title_pref_enable_quiet_hours">Habilitar horário de sossego</string>
<string name="pref_quiet_hours_summary">As notificações serão silenciadas no horário de sossego.</string>
<string name="pref_expert_options_other">Outras</string>
- <string name="pref_autojoin">Sincronizar com os favoritos</string>
- <string name="pref_autojoin_summary">Entre nas conversas em grupo automaticamente caso isso esteja definido no favorito</string>
+ <string name="pref_autojoin">Sincronizar favoritos</string>
+ <string name="pref_autojoin_summary">Define a flag \"autojoin\" ao entrar ou sair de uma sala e reage a modificações feitas por outros clientes.</string>
<string name="toast_message_omemo_fingerprint">Impressão digital OMEMO copiada para a área de transferência</string>
<string name="conference_banned">Você foi banido desta conversa em grupo</string>
<string name="conference_members_only">Somente membros podem entrar nessa conversa em grupo</string>
@@ -306,6 +307,7 @@
<string name="conference_kicked">Você foi retirado desta conversa em grupo</string>
<string name="conference_shutdown">A conversa em grupo foi encerrada</string>
<string name="conference_unknown_error">Você não está mais nesta conversa em grupo</string>
+ <string name="conference_technical_problems">Você saiu desta conversa em grupo devido a razões técnicas</string>
<string name="using_account">usando a conta %s</string>
<string name="hosted_on">hospedado em %s</string>
<string name="checking_x">Verificando %s no host HTTP</string>
@@ -775,6 +777,7 @@
<string name="messages_channel_name">Mensagens</string>
<string name="incoming_calls_channel_name">Chamadas recebidas</string>
<string name="ongoing_calls_channel_name">Chamadas em andamento</string>
+ <string name="missed_calls_channel_name">Chamadas perdidas</string>
<string name="silent_messages_channel_name">Silenciar mensagens</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Entregas não efetuadas</string>
@@ -915,6 +918,8 @@
<string name="make_call">Fazer chamada</string>
<string name="rtp_state_incoming_call">Recebendo chamada</string>
<string name="rtp_state_incoming_video_call">Recebendo chamada de vídeo</string>
+ <string name="rtp_state_content_add_video">Mudar para videochamada?</string>
+ <string name="rtp_state_content_add">Adicionar outras trilhas?</string>
<string name="rtp_state_connecting">Conectando</string>
<string name="rtp_state_connected">Conectado</string>
<string name="rtp_state_reconnecting">Reconectando</string>
@@ -942,6 +947,21 @@
<string name="outgoing_call">Chamada realizada</string>
<string name="outgoing_call_duration">Chamada realizada · %s</string>
<string name="missed_call">Chamada perdida</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d chamada perdida para %2$s</item>
+ <item quantity="many">%1$d chamadas perdidas para %2$s</item>
+ <item quantity="other">%1$d chamadas perdidas para %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d chamada perdida</item>
+ <item quantity="many">%d chamadas perdidas</item>
+ <item quantity="other">%d chamadas perdidas</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d chamadas perdidas de %2$d contato</item>
+ <item quantity="many">%1$d chamadas perdidas de %2$d contatos</item>
+ <item quantity="other">%1$d chamadas perdidas de %2$d contatos</item>
+ </plurals>
<string name="audio_call">Chamada de áudio</string>
<string name="video_call">Chamada de vídeo</string>
<string name="help">Ajuda</string>
@@ -989,5 +1009,9 @@
<string name="account_registrations_are_not_supported">O registro de contas não está ativo</string>
<string name="no_xmpp_adddress_found">Não foi encontrado nenhum endereço XMPP</string>
<string name="account_status_temporary_auth_failure">Falha temporária na autenticação</string>
+ <string name="delete_avatar">Excluir avatar</string>
+ <string name="audio_video_disabled_tor">As chamadas estão desabilitadas ao usar Tor</string>
+ <string name="switch_to_video">Mudar para vídeo</string>
+ <string name="reject_switch_to_video">Recusar requisição de mudança para vídeo</string>
</resources>
@@ -173,6 +173,7 @@
<string name="account_status_tls_error_domain">Domeniul nu se poate verifica</string>
<string name="account_status_policy_violation">Încălcare condiții furnizare serviciu</string>
<string name="account_status_incompatible_server">Server incompatibil</string>
+ <string name="account_status_incompatible_client">Client incompatibil</string>
<string name="account_status_stream_error">Eroare de date</string>
<string name="account_status_stream_opening_error">Eroare deschidere flux de date</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -297,8 +298,8 @@
<string name="title_pref_enable_quiet_hours">Activează orar de liniște</string>
<string name="pref_quiet_hours_summary">Notificările vor fi reduse la tăcere în timpul orelor de liniște</string>
<string name="pref_expert_options_other">Altele</string>
- <string name="pref_autojoin">Sincronizează cu semnele de carte</string>
- <string name="pref_autojoin_summary">Alătură-te discuției de grup în mod automat dacă semnul de carte este setat așa</string>
+ <string name="pref_autojoin">Sincronizare semne de carte</string>
+ <string name="pref_autojoin_summary">Setați \"autojoin\" la intrarea sau ieșirea dintr-o discuție de grup și reacționați la modificările efectuate de alți clienți.</string>
<string name="toast_message_omemo_fingerprint">Amprentă OMEMO copiată în memorie</string>
<string name="conference_banned">V-a fost interzis accesul la această discuție de grup</string>
<string name="conference_members_only">Această discuție de grup este rezervată membrilor</string>
@@ -306,6 +307,7 @@
<string name="conference_kicked">Ați fost dat(ă) afară din această discuție de grup</string>
<string name="conference_shutdown">Discuția de grup a fost închisă</string>
<string name="conference_unknown_error">Nu mai sunteți în această discuție de grup</string>
+ <string name="conference_technical_problems">Ați părăsit această discuție de grup din motive tehnice</string>
<string name="using_account">folosind cont %s</string>
<string name="hosted_on">găzduit pe %s</string>
<string name="checking_x">Verifica %s pe gazda HTTP</string>
@@ -775,6 +777,7 @@
<string name="messages_channel_name">Mesaje</string>
<string name="incoming_calls_channel_name">Apeluri primite</string>
<string name="ongoing_calls_channel_name">Apeluri în curs</string>
+ <string name="missed_calls_channel_name">Apeluri pierdute</string>
<string name="silent_messages_channel_name">Mesaje silențioase</string>
<string name="silent_messages_channel_description">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).</string>
<string name="delivery_failed_channel_name">Trimiteri eșuate</string>
@@ -915,6 +918,8 @@
<string name="make_call">Apelează</string>
<string name="rtp_state_incoming_call">Apel primit</string>
<string name="rtp_state_incoming_video_call">Apel video primit</string>
+ <string name="rtp_state_content_add_video">Comută la apel video?</string>
+ <string name="rtp_state_content_add">Adăugați canale suplimentare?</string>
<string name="rtp_state_connecting">Conectare</string>
<string name="rtp_state_connected">Conectat</string>
<string name="rtp_state_reconnecting">Reconectare</string>
@@ -942,6 +947,21 @@
<string name="outgoing_call">Apel efectuat</string>
<string name="outgoing_call_duration">Apel efectuat · %s</string>
<string name="missed_call">Apel pierdut</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d apel pierdut de la %2$s</item>
+ <item quantity="few">%1$d apeluri pierdute de la %2$s</item>
+ <item quantity="other">%1$d de apeluri pierdute de la %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d apel pierdut</item>
+ <item quantity="few">%d apeluri pierdute</item>
+ <item quantity="other">%d de apeluri pierdute</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d apel pierdut de la %2$d contact</item>
+ <item quantity="few">%1$d apeluri pierdute de la %2$d contact</item>
+ <item quantity="other">%1$d de apeluri pierdute de la %2$d contacte</item>
+ </plurals>
<string name="audio_call">Apel audio</string>
<string name="video_call">Apel video</string>
<string name="help">Ajutor</string>
@@ -989,5 +1009,9 @@
<string name="account_registrations_are_not_supported">Nu este posibilă înregistrarea unui cont</string>
<string name="no_xmpp_adddress_found">Nu a fost găsită o adresă XMPP</string>
<string name="account_status_temporary_auth_failure">Eroare temporară de autentificare</string>
+ <string name="delete_avatar">Șterge avatar</string>
+ <string name="audio_video_disabled_tor">Apelurile sunt dezactivate atunci când utilizați Tor</string>
+ <string name="switch_to_video">Comută la video</string>
+ <string name="reject_switch_to_video">Respinge solicitarea de comutare la video</string>
</resources>
@@ -300,8 +300,6 @@
<string name="title_pref_enable_quiet_hours">Включить режим «тихих часов»</string>
<string name="pref_quiet_hours_summary">Уведомления будут отключены во время «тихих часов»</string>
<string name="pref_expert_options_other">Другие</string>
- <string name="pref_autojoin">Синхронизировать с закладками</string>
- <string name="pref_autojoin_summary">Автоматически заходить в конференции при установленном флаге в настройках закладки</string>
<string name="toast_message_omemo_fingerprint">OMEMO-отпечаток скопирован в буфер обмена</string>
<string name="conference_banned">Вы заблокированы в этой конференции</string>
<string name="conference_members_only">Эта конференция — только для участников</string>
@@ -287,8 +287,6 @@
<string name="title_pref_enable_quiet_hours">Povoliť tichý režim</string>
<string name="pref_quiet_hours_summary">Upozornenia budú počas tichého režimu stlmené</string>
<string name="pref_expert_options_other">Ďalší</string>
- <string name="pref_autojoin">Synchronizovať so záložkami</string>
- <string name="pref_autojoin_summary">Automaticky sa pripojiť k skupinovému rozhovoru, ak to hovorí záložka</string>
<string name="toast_message_omemo_fingerprint">OMEMO odtlačok skopírovaný do schránky</string>
<string name="conference_banned">Ste zakázaný na tomto skupinovom rozhovore</string>
<string name="conference_members_only">Skupinový rozhovor len pre členov</string>
@@ -293,8 +293,6 @@
<string name="title_pref_enable_quiet_hours">Укључи тихе сате</string>
<string name="pref_quiet_hours_summary">Обавештења ће бити ућуткана за време тихих сати</string>
<string name="pref_expert_options_other">Остало</string>
- <string name="pref_autojoin">Синхронизуј са обележивачима</string>
- <string name="pref_autojoin_summary">Аутоматски се придружите групним ћаскањима по поставци обележивача</string>
<string name="toast_message_omemo_fingerprint">ОМЕМО отисак копиран на клипборд</string>
<string name="conference_banned">Забрањен вам је приступ овом групном ћаскању</string>
<string name="conference_members_only">Ово групно ћаскање је само за чланове</string>
@@ -294,8 +294,6 @@
<string name="title_pref_enable_quiet_hours">Aktivera tysta timmar</string>
<string name="pref_quiet_hours_summary">Notifieringar kommer vara tysta under tysta timmar</string>
<string name="pref_expert_options_other">Annat</string>
- <string name="pref_autojoin">Synkronisera med bokmärken</string>
- <string name="pref_autojoin_summary">Gå med i gruppchattar automatiskt om bokmärket säger det</string>
<string name="toast_message_omemo_fingerprint">OMEMO-fingeravtryck kopierat till urklipp</string>
<string name="conference_banned">Du är avstängd från denna gruppchatt</string>
<string name="conference_members_only">Denna gruppchatt är endast för medlemmar</string>
@@ -316,8 +316,6 @@
<string name="title_pref_enable_quiet_hours">Włōncz godziny cisze</string>
<string name="pref_quiet_hours_summary">Powiadōmiynia bydōm wyciszōne we ôbranych godzinach</string>
<string name="pref_expert_options_other">Inksze</string>
- <string name="pref_autojoin">Synchrōnizuj ze zokłodkami</string>
- <string name="pref_autojoin_summary">Przistympuj do godek grupowych autōmatycznie, jeźli tak pado zokłodka</string>
<string name="toast_message_omemo_fingerprint">Ôdcisk klucza OMEMO bōł skopiowany do skrytki</string>
<string name="conference_banned">Ôd tyj grupy mosz wykluczynie</string>
<string name="conference_members_only">Kōnferyncyjo ino dlo czōnkōw</string>
@@ -170,6 +170,7 @@
<string name="account_status_tls_error_domain">Alan adı doğrulanamıyor</string>
<string name="account_status_policy_violation">Politika ihlali</string>
<string name="account_status_incompatible_server">Sunucu uyuşmazlığı</string>
+ <string name="account_status_incompatible_client">Uyumsuz istemci</string>
<string name="account_status_stream_error">Akış hatası</string>
<string name="account_status_stream_opening_error">Akış açılım hatası</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -294,8 +295,7 @@
<string name="title_pref_enable_quiet_hours">Sessiz saatleri etkinleştir</string>
<string name="pref_quiet_hours_summary">Bildirimler sessiz saatler boyunca sessize alınacaktır</string>
<string name="pref_expert_options_other">Diğer</string>
- <string name="pref_autojoin">Yer imleri ile senkronize et.</string>
- <string name="pref_autojoin_summary">Yer imleri öyle belirtmişse grup konuşmalarına otomatik olarak katıl.</string>
+ <string name="pref_autojoin">Yer imleriyle senkronize et</string>
<string name="toast_message_omemo_fingerprint">OMEMO parmak izi panoya kopyalandı</string>
<string name="conference_banned">Bu grup konuşmasından menedildiniz</string>
<string name="conference_members_only">Bu grup konuşması yalnızca üyeleri içindir</string>
@@ -303,6 +303,7 @@
<string name="conference_kicked">Bu grup konuşmasından atıldınız</string>
<string name="conference_shutdown">Grup konuşması kapatıldı</string>
<string name="conference_unknown_error">Artık bu grup konuşmasında değilsiniz</string>
+ <string name="conference_technical_problems">Teknik sebeplerden dolayı bu grup sohbetinden ayrıldınız</string>
<string name="using_account">%s hesabını kullanarak</string>
<string name="hosted_on">%sev sahipliğinde</string>
<string name="checking_x">HTTP sunucusundaki %s denetleniyor</string>
@@ -417,6 +418,7 @@
<string name="video">video</string>
<string name="image">görüntü</string>
<string name="vector_graphic">Vektör grafik</string>
+ <string name="multimedia_file">Multimedya dosyası</string>
<string name="pdf_document">PDF belgesi</string>
<string name="apk">Android uygulaması</string>
<string name="vcard">Kişi</string>
@@ -763,6 +765,7 @@
<string name="messages_channel_name">İletiler</string>
<string name="incoming_calls_channel_name">Gelen aramalar</string>
<string name="ongoing_calls_channel_name">Yapılan aramalar</string>
+ <string name="missed_calls_channel_name">Cevapsız aramalar</string>
<string name="silent_messages_channel_name">Sessiz iletiler</string>
<string name="silent_messages_channel_description">Bu bildirim grubu, bildirimlerin herhangi bir ses çıkarmaması gerektiğini belirtmekte kullanılır. Mesela başka bir cihazda aktif olunduğunda (Mühlet)</string>
<string name="delivery_failed_channel_name">Başarısız gönderiler</string>
@@ -930,6 +933,18 @@
<string name="outgoing_call">Yapılan arama</string>
<string name="outgoing_call_duration">Yapılan arama. %s</string>
<string name="missed_call">Cevapsız arama</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one"> %2$s tarafından %1$d cevapsız çağrı</item>
+ <item quantity="other">%2$s tarafından %1$d cevapsız çağrı </item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d cevapsız çağrı</item>
+ <item quantity="other">%d cevapsız çağrı </item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%2$d tarafından %1$d cevapsız çağrı</item>
+ <item quantity="other">%2$d kişi tarafından %1$d cevapsız çağrı</item>
+ </plurals>
<string name="audio_call">Sesli arama</string>
<string name="video_call">Görüntülü arama</string>
<string name="help">Yardım</string>
@@ -974,4 +989,7 @@
<string name="plain_text_document">Düz metin dosyası</string>
<string name="account_registrations_are_not_supported">Hesap kayıtları desteklenmemektedir.</string>
<string name="no_xmpp_adddress_found">Herhangi bir XMPP adresi bulunamadı</string>
+ <string name="account_status_temporary_auth_failure">Geçici doğrulama hatası</string>
+ <string name="delete_avatar">Avatar\'ı sil</string>
+ <string name="audio_video_disabled_tor">Tor kullanırken çağrılar devre dışı</string>
</resources>
@@ -276,8 +276,6 @@
<string name="title_pref_enable_quiet_hours">Увімкнути години тиші</string>
<string name="pref_quiet_hours_summary">Сповіщення не звучатимуть під час годин тиші</string>
<string name="pref_expert_options_other">Інше</string>
- <string name="pref_autojoin">Синхронізовувати з закладками</string>
- <string name="pref_autojoin_summary">Приєднуватися до груп і полишати їх відповідно до опції автоматичного приєднання, вибраної в закладках.</string>
<string name="toast_message_omemo_fingerprint">Цифровий підпис OMEMO скопійовано</string>
<string name="conference_banned">Вам заборонили доступ до цієї групи</string>
<string name="conference_members_only">Ця група лише для учасників</string>
@@ -291,8 +291,6 @@
<string name="title_pref_enable_quiet_hours">Bật giờ yên lặng</string>
<string name="pref_quiet_hours_summary">Thông báo sẽ được tắt trong giờ yên lặng</string>
<string name="pref_expert_options_other">Khác</string>
- <string name="pref_autojoin">Đồng bộ hoá bằng dấu trang</string>
- <string name="pref_autojoin_summary">Tự động tham gia các cuộc trò chuyện nhóm nếu dấu trang bảo thế</string>
<string name="toast_message_omemo_fingerprint">Đã sao chép mã vân tay OMEMO vào bộ nhớ tạm</string>
<string name="conference_banned">Bạn bị cấm khỏi cuộc trò chuyện nhóm này</string>
<string name="conference_members_only">Cuộc trò chuyện nhóm này chỉ dành cho thành viên</string>
@@ -167,6 +167,7 @@
<string name="account_status_tls_error_domain">域名不可验证</string>
<string name="account_status_policy_violation">违反政策</string>
<string name="account_status_incompatible_server">服务器不兼容</string>
+ <string name="account_status_incompatible_client">不兼容的客户端</string>
<string name="account_status_stream_error">流错误</string>
<string name="account_status_stream_opening_error">流打开错误</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -291,8 +292,8 @@
<string name="title_pref_enable_quiet_hours">启用静默时间段</string>
<string name="pref_quiet_hours_summary">在静默时间段内通知将保持静音</string>
<string name="pref_expert_options_other">其他</string>
- <string name="pref_autojoin">与书签同步</string>
- <string name="pref_autojoin_summary">根据书签标记自动加入群聊。</string>
+ <string name="pref_autojoin">同步书签</string>
+ <string name="pref_autojoin_summary">加入或离开多用户聊天时设置 “autojoin\" 标志,并回应其他客户端所做更改。</string>
<string name="toast_message_omemo_fingerprint">OMEMO指纹已拷贝到剪贴板</string>
<string name="conference_banned">您被封禁了</string>
<string name="conference_members_only">这个群聊只允许成员聊天</string>
@@ -300,6 +301,7 @@
<string name="conference_kicked">您被从此群聊踢出</string>
<string name="conference_shutdown">这个群聊已被关闭</string>
<string name="conference_unknown_error">您已不在该群组</string>
+ <string name="conference_technical_problems">你出于技术原因离开了群聊</string>
<string name="using_account">使用帐户%s</string>
<string name="hosted_on">托管于%s</string>
<string name="checking_x">正在HTTP服务器中检查%s</string>
@@ -753,6 +755,7 @@
<string name="messages_channel_name">消息</string>
<string name="incoming_calls_channel_name">来电</string>
<string name="ongoing_calls_channel_name">正在进行的通话</string>
+ <string name="missed_calls_channel_name">未接来电</string>
<string name="silent_messages_channel_name">无声消息</string>
<string name="silent_messages_channel_description">此通知组用于显示不应触发任何声音的通知。 例如,当在另一个设备上激活时(宽限期)。</string>
<string name="delivery_failed_channel_name">发送失败</string>
@@ -893,6 +896,8 @@
<string name="make_call">进行通话</string>
<string name="rtp_state_incoming_call">来电</string>
<string name="rtp_state_incoming_video_call">视频来电</string>
+ <string name="rtp_state_content_add_video">切换到视频通话?</string>
+ <string name="rtp_state_content_add">添加额外轨道?</string>
<string name="rtp_state_connecting">正在连接</string>
<string name="rtp_state_connected">已连接</string>
<string name="rtp_state_reconnecting">重新连接</string>
@@ -920,6 +925,15 @@
<string name="outgoing_call">去电</string>
<string name="outgoing_call_duration">去电 · %s</string>
<string name="missed_call">未接电话</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="other">%1$d 错过了来自 %2$s 的电话</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="other">%d 个未接电话</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="other">%1$d 个未接电话,来自 %2$d 位联系人</item>
+ </plurals>
<string name="audio_call">语音通话</string>
<string name="video_call">视频通话</string>
<string name="help">帮助</string>
@@ -963,5 +977,9 @@
<string name="account_registrations_are_not_supported">不支持注册账户</string>
<string name="no_xmpp_adddress_found">未找到 XMPP 地址</string>
<string name="account_status_temporary_auth_failure">临时认证失败</string>
+ <string name="delete_avatar">删除群聊</string>
+ <string name="audio_video_disabled_tor">使用 Tor 时通话被禁用</string>
+ <string name="switch_to_video">切换到视频</string>
+ <string name="reject_switch_to_video">拒绝切换到视频的请求</string>
</resources>
@@ -124,8 +124,11 @@
<string name="pref_prevent_screenshots">防止截圖</string>
<string name="pref_prevent_screenshots_summary">在多工畫面隱藏應用程式聯絡人並且封鎖螢幕截圖</string>
<string name="pref_ui_options">UI</string>
+ <string name="openpgp_error">OpenKeychain 產生一個錯誤。</string>
+ <string name="bad_key_for_encryption">錯誤加密金鑰</string>
<string name="accept">接受</string>
<string name="error">產生了一個錯誤</string>
+ <string name="recording_error">錯誤</string>
<string name="your_account">你的帳戶</string>
<string name="send_presence_updates">發送線上連絡人列表更新</string>
<string name="receive_presence_updates">接收線上連絡人列表更新</string>
@@ -134,6 +137,7 @@
<string name="attach_take_picture">照相</string>
<string name="preemptively_grant">預先同意訂閱請求</string>
<string name="error_not_an_image_file">選擇的檔案不是一張圖片</string>
+ <string name="error_compressing_image">無法轉換圖片檔案</string>
<string name="error_file_not_found">找不到檔案</string>
<string name="error_io_exception">常規的 I/O 錯誤。可能是存儲空間不足?</string>
<string name="account_status_unknown">未知</string>
@@ -145,11 +149,17 @@
<string name="account_status_not_found">未找到伺服器</string>
<string name="account_status_no_internet">未連接網路</string>
<string name="account_status_regis_fail">註冊失敗</string>
- <string name="account_status_regis_conflict"> 用戶名已存在</string>
+ <string name="account_status_regis_conflict">使用者名稱已被使用</string>
<string name="account_status_regis_success">註冊完成</string>
+ <string name="account_status_regis_not_sup">伺服器不支援註冊</string>
+ <string name="account_status_regis_invalid_token">無效的註冊權杖</string>
+ <string name="account_status_tls_error">TLS 協商失敗</string>
+ <string name="account_status_tls_error_domain">網域不可驗證</string>
<string name="account_status_policy_violation">違反政策</string>
<string name="account_status_incompatible_server">伺服器不相容</string>
+ <string name="account_status_incompatible_client">不兼容的客戶端</string>
<string name="account_status_stream_error">串流錯誤</string>
+ <string name="account_status_stream_opening_error">串流開啟錯誤</string>
<string name="encryption_choice_unencrypted">TLS</string>
<string name="encryption_choice_otr">OTR</string>
<string name="encryption_choice_pgp">OpenPGP</string>
@@ -160,8 +170,10 @@
<string name="mgmt_account_publish_pgp">發佈 OpenPGP 公開金鑰</string>
<string name="unpublish_pgp">移除 OpenPGP 公開金鑰</string>
<string name="unpublish_pgp_message">確定要移除上線狀態中的 OpenPGP 公開金鑰嗎?\n這樣一來,你的聯絡人就無法傳送以 OpenPGP 加密的訊息給你了。</string>
+ <string name="openpgp_has_been_published">OpenPGP 公開金鑰已發佈 </string>
<string name="mgmt_account_enable">啟用帳戶</string>
<string name="mgmt_account_are_you_sure">確定?</string>
+ <string name="mgmt_account_delete_confirm_text">刪除帳戶將清除您全部的會話記錄</string>
<string name="attach_record_voice">錄音</string>
<string name="account_settings_jabber_id">XMPP 位址</string>
<string name="block_jabber_id">封鎖 XMPP 位址</string>
@@ -185,19 +197,26 @@
<string name="server_info_unavailable">無效</string>
<string name="missing_public_keys">缺少公開金鑰通知</string>
<string name="last_seen_now">剛剛查看過</string>
+ <string name="last_seen_min">一分鐘前查看過</string>
<string name="last_seen_mins">%d 分鐘前查看過</string>
+ <string name="last_seen_hour">一小時前查看過</string>
<string name="last_seen_hours">%d 小時前查看過</string>
+ <string name="last_seen_day">一天前查看過</string>
<string name="last_seen_days">%d 天前查看過</string>
+ <string name="install_openkeychain">訊息已加密。請安裝 OpenKeychain 以解密該訊息。</string>
+ <string name="openpgp_messages_found">發現新的 OpenPGP 加密訊息</string>
<string name="openpgp_key_id">OpenPGP 金鑰 ID</string>
<string name="omemo_fingerprint">OMEMO 指紋</string>
<string name="omemo_fingerprint_x509">v\\OMEMO 指紋</string>
+ <string name="omemo_fingerprint_selected_message">OMEMO 指紋 (訊息來源)</string>
+ <string name="omemo_fingerprint_x509_selected_message">v\\OMEMO 指紋 (訊息來源)</string>
<string name="other_devices">其他裝置</string>
<string name="trust_omemo_fingerprints">信任的 OMEMO 指紋</string>
<string name="fetching_keys">正在擷取金鑰…</string>
<string name="done">完成</string>
<string name="decrypt">解密</string>
<string name="bookmarks">書籤</string>
- <string name="search">尋找</string>
+ <string name="search">搜尋</string>
<string name="enter_contact">輸入聯絡人</string>
<string name="delete_contact">刪除聯絡人</string>
<string name="view_contact_details">檢視聯絡人詳細資料</string>
@@ -211,17 +230,28 @@
<string name="channel_bare_jid_example">channel@conference.example.com</string>
<string name="save_as_bookmark">儲存為書籤</string>
<string name="delete_bookmark">刪除書籤</string>
+ <string name="destroy_room">解散群組聊天</string>
+ <string name="destroy_channel">解散頻道</string>
+ <string name="could_not_destroy_room">不能解散群組聊天</string>
+ <string name="could_not_destroy_channel">無法解散頻道</string>
+ <string name="action_edit_subject">編輯群組聊天主題</string>
<string name="topic">主旨</string>
<string name="joining_conference">正在加入群組聊天…</string>
<string name="leave">離開</string>
<string name="contact_added_you">聯絡人已新增至你的聯絡人清單</string>
<string name="add_back">新增回</string>
<string name="contact_has_read_up_to_this_point">%s 已讀此句</string>
+ <string name="contacts_have_read_up_to_this_point">%s 已讀到這裏</string>
+ <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s 和其他 %2$d 位已經讀到這裏</string>
+ <string name="everyone_has_read_up_to_this_point">所有人已讀到這裏</string>
<string name="publish">發佈</string>
+ <string name="touch_to_choose_picture">輕觸頭像以從相片庫中選擇相片</string>
<string name="publishing">正在發佈…</string>
<string name="error_publish_avatar_server_reject">伺服器拒絕了您的發佈請求</string>
+ <string name="error_publish_avatar_converting">無法轉換你的相片</string>
<string name="error_saving_avatar">不能將頭像保存至磁片</string>
<string name="or_long_press_for_default">(或長按按鈕將返回預設頭像)</string>
+ <string name="error_publish_avatar_no_server_support">你的伺服器不支援發佈頭像</string>
<string name="private_message">私聊</string>
<string name="private_message_to">至 %s</string>
<string name="send_private_message_to">送私密訊息給 %s</string>
@@ -249,13 +279,23 @@
<string name="pref_quiet_hours_summary">在靜默時間段內通知將保持靜音</string>
<string name="pref_expert_options_other">其他</string>
<string name="pref_autojoin">同步處理書籤</string>
+ <string name="toast_message_omemo_fingerprint">OMEMO 指紋已複製到剪貼簿</string>
+ <string name="conference_banned">你已被這群組聊天封鎖</string>
+ <string name="conference_members_only">這群組聊天只有會員可以加入</string>
+ <string name="conference_resource_constraint">資源限制</string>
+ <string name="conference_kicked">你已被踢出群組聊天</string>
+ <string name="conference_shutdown">群組聊天已被關閉</string>
+ <string name="conference_unknown_error">你已不在該群組聊天</string>
+ <string name="conference_technical_problems">出於技術性原因,你離開了群組聊天</string>
<string name="using_account">用帳戶 %s</string>
+ <string name="hosted_on">託管於 %s</string>
<string name="checking_x">正在 HTTP 伺服器中檢查 %s</string>
<string name="not_connected_try_again">你沒有連接。請稍後重試</string>
<string name="check_x_filesize">檢查 %s 大小</string>
<string name="check_x_filesize_on_host">在 %2$s 上檢查 %1$s 的大小</string>
<string name="message_options">訊息選項</string>
<string name="quote">引用</string>
+ <string name="paste_as_quote">作為引用貼上</string>
<string name="copy_original_url">拷貝原始URL</string>
<string name="send_again">再次發送</string>
<string name="file_url">檔案 URL</string>
@@ -269,6 +309,7 @@
<string name="account_details">帳戶詳情</string>
<string name="confirm">確認</string>
<string name="try_again">再試一遍</string>
+ <string name="pref_keep_foreground_service">前臺服務</string>
<string name="pref_keep_foreground_service_summary">防止作業系統中斷你的連接</string>
<string name="pref_create_backup">建立備份</string>
<string name="pref_create_backup_summary">備份檔案將被儲存至 %s</string>
@@ -289,13 +330,21 @@
<string name="x_file_offered_for_download">可以下載 %s</string>
<string name="cancel_transmission">取消傳送</string>
<string name="file_transmission_failed">無法分享檔案</string>
+ <string name="file_transmission_cancelled">檔案傳輸已取消</string>
<string name="file_deleted">檔案已刪除</string>
+ <string name="no_application_found_to_open_file">沒有可以打開檔案的應用程式</string>
+ <string name="no_application_found_to_open_link">沒有可以打開連結的應用程式</string>
+ <string name="no_application_found_to_view_contact">沒有可以查看聯絡人的應用程式</string>
+ <string name="pref_show_dynamic_tags">動態標簽</string>
<string name="pref_show_dynamic_tags_summary">在連絡人下方顯示唯讀標籤</string>
<string name="enable_notifications">啟用通知</string>
+ <string name="no_conference_server_found">未找到群組聊天伺服器</string>
+ <string name="conference_creation_failed">未能建立群組聊天</string>
<string name="account_image_description">帳戶頭像</string>
<string name="copy_omemo_clipboard_description">拷貝 OMEMO 指紋到剪貼板</string>
<string name="regenerate_omemo_key">重新生成 OMEMO 金鑰</string>
<string name="clear_other_devices">清除設備</string>
+ <string name="error_trustkeys_title">出錯了</string>
<string name="fetching_history_from_server">從伺服器獲取歷史記錄</string>
<string name="no_more_history_on_server">伺服器上沒有更多歷史記錄</string>
<string name="updating">更新中…</string>
@@ -304,47 +353,71 @@
<string name="change_password">修改密碼</string>
<string name="current_password">當前密碼</string>
<string name="new_password">新密碼</string>
+ <string name="password_should_not_be_empty">密碼不能留空</string>
<string name="enable_all_accounts">啟用所有帳戶</string>
<string name="disable_all_accounts">禁用所有帳戶</string>
<string name="perform_action_with">選擇一個操作</string>
<string name="no_affiliation">沒有從屬關係</string>
<string name="no_role">離線</string>
<string name="outcast">拋棄</string>
- <string name="member">成員</string>
- <string name="advanced_mode">高級模式</string>
+ <string name="member">會員</string>
+ <string name="advanced_mode">進階模式</string>
+ <string name="grant_membership">授予會員許可權</string>
+ <string name="remove_membership">撤銷會員許可權</string>
<string name="grant_admin_privileges">授予管理員許可權</string>
<string name="remove_admin_privileges">吊銷管理員許可權</string>
+ <string name="grant_owner_privileges">授予擁有者許可權</string>
+ <string name="remove_owner_privileges">撤銷擁有者許可權</string>
+ <string name="remove_from_room">從群組聊天移除</string>
+ <string name="remove_from_channel">從頻道中移除</string>
<string name="could_not_change_affiliation">不能修改 %s 的從屬關係</string>
- <string name="ban_now">現在遮罩</string>
+ <string name="ban_from_conference">從群組聊天封鎖</string>
+ <string name="ban_from_channel">從頻道中封鎖</string>
+ <string name="removing_from_public_conference">你正在嘗試從公用頻道中移除 %s。只有永遠封鎖此用戶方能做到。</string>
+ <string name="ban_now">立即封鎖</string>
<string name="could_not_change_role">不能修改 %s 的角色</string>
- <string name="members_only">私密,只有成員可以加入</string>
+ <string name="conference_options">設置私人群組聊天</string>
+ <string name="channel_options">設置公用頻道</string>
+ <string name="members_only">私密,只有會員可以加入</string>
+ <string name="non_anonymous">令所有人可以看見 XMPP 地址</string>
+ <string name="moderated">使頻道受到管理</string>
<string name="you_are_not_participating">您尚未參與</string>
+ <string name="modified_conference_options">成功修改群組聊天選項!</string>
+ <string name="could_not_modify_conference_options">無法修改群組聊天選項</string>
<string name="never">從不</string>
<string name="until_further_notice">直到新的通知</string>
+ <string name="snooze">延遲</string>
+ <string name="reply">回覆</string>
+ <string name="mark_as_read">標示為已讀</string>
<string name="pref_input_options">輸入</string>
- <string name="pref_enter_is_send">回車是發送</string>
- <string name="pref_display_enter_key">顯示回車鍵</string>
- <string name="pref_display_enter_key_summary">改變表情鍵為回車鍵</string>
+ <string name="pref_enter_is_send">Enter 鍵傳送</string>
+ <string name="pref_display_enter_key">顯示 Enter 鍵</string>
+ <string name="pref_display_enter_key_summary">變更表情符號鍵為 Enter 鍵</string>
<string name="audio">音訊</string>
<string name="video">影片</string>
- <string name="image">圖像</string>
- <string name="pdf_document">PDF 文檔</string>
- <string name="apk">Android App</string>
- <string name="vcard">連絡人</string>
+ <string name="image">圖片</string>
+ <string name="vector_graphic">向量圖形</string>
+ <string name="multimedia_file">多媒體檔案</string>
+ <string name="pdf_document">PDF 文件</string>
+ <string name="apk">Android 應用程式</string>
+ <string name="vcard">聯絡人</string>
<string name="avatar_has_been_published">頭像已經發佈!</string>
<string name="sending_x_file">發送中 %s</string>
<string name="offering_x_file">提供中 %s</string>
<string name="hide_offline">隱藏離線連絡人</string>
- <string name="contact_is_typing">%s 正在輸入中…</string>
- <string name="contact_has_stopped_typing">%s 停止輸入了</string>
- <string name="contacts_are_typing">%s 正在輸入中…</string>
- <string name="contacts_have_stopped_typing">%s 停止輸入了</string>
+ <string name="contact_is_typing">%s 正在輸入…</string>
+ <string name="contact_has_stopped_typing">%s 已停止輸入</string>
+ <string name="contacts_are_typing">%s 正在輸入…</string>
+ <string name="contacts_have_stopped_typing">%s 已停止輸入</string>
<string name="pref_chat_states">鍵盤輸入通知</string>
<string name="pref_chat_states_summary">讓聯絡人知道你正在寫訊息送給它們</string>
- <string name="send_location">發送位置</string>
+ <string name="send_location">傳送位置</string>
<string name="show_location">顯示位置</string>
+ <string name="no_application_found_to_display_location">找不到可以顯示位置的應用程式</string>
<string name="location">位置</string>
<string name="title_undo_swipe_out_conversation">Conversation 已關閉</string>
+ <string name="title_undo_swipe_out_group_chat">離開私人群組聊天</string>
+ <string name="title_undo_swipe_out_channel">離開了公用頻道</string>
<string name="pref_dont_trust_system_cas_title">不信任系統的憑證機構</string>
<string name="pref_dont_trust_system_cas_summary">所有證書必須人工通過</string>
<string name="pref_remove_trusted_certificates_title">移除證書</string>
@@ -356,11 +429,15 @@
<plurals name="toast_delete_certificates">
<item quantity="other">%d 個證書已被刪除</item>
</plurals>
+ <string name="pref_quick_action_summary">以快速動作代替「發送」按鈕</string>
<string name="pref_quick_action">快速動作</string>
<string name="none">無</string>
<string name="recently_used">最近使用過的</string>
<string name="choose_quick_action">選擇快速動作</string>
+ <string name="search_contacts">搜尋聯絡人</string>
+ <string name="search_bookmarks">搜尋書籤</string>
<string name="send_private_message">送私密訊息</string>
+ <string name="user_has_left_conference">%1$s 離開了群組聊天</string>
<string name="username">用戶名</string>
<string name="username_hint">用戶名</string>
<string name="invalid_username">該用戶名無效</string>
@@ -368,17 +445,31 @@
<string name="download_failed_file_not_found">下載失敗:找不到檔案</string>
<string name="download_failed_could_not_connect">下載失敗:無法連接到伺服器</string>
<string name="download_failed_could_not_write_file">下載失敗:無法寫入檔案</string>
+ <string name="download_failed_invalid_file">下載失敗:無效的檔案</string>
<string name="account_status_tor_unavailable">Tor network 不可用</string>
<string name="account_status_bind_failure">綁定失敗</string>
+ <string name="account_status_host_unknown">伺服器不負責此網域名稱</string>
<string name="server_info_broken">損壞</string>
+ <string name="pref_presence_settings">在線狀態</string>
+ <string name="pref_away_when_screen_off">裝置上鎖時離開</string>
+ <string name="pref_away_when_screen_off_summary">裝置上鎖時顯示為離開</string>
+ <string name="pref_dnd_on_silent_mode">靜音模式時忙碌</string>
+ <string name="pref_dnd_on_silent_mode_summary">靜音模式時顯示為忙碌</string>
<string name="pref_treat_vibrate_as_silent">靜音模式開啟振動</string>
+ <string name="pref_treat_vibrate_as_dnd_summary">裝置振動時顯示為忙碌</string>
<string name="pref_show_connection_options">高級連接設置</string>
<string name="pref_show_connection_options_summary">註冊帳戶時顯示主機名稱和埠</string>
<string name="hostname_example">xmpp.example.com</string>
+ <string name="action_add_account_with_certificate">以證書登入</string>
+ <string name="unable_to_parse_certificate">無法解析證書</string>
<string name="mam_prefs">壓縮設置</string>
<string name="server_side_mam_prefs">服務端壓縮設置</string>
<string name="fetching_mam_prefs">正在獲取壓縮設置。請稍後...</string>
+ <string name="unable_to_fetch_mam_prefs">無法獲取封存設置</string>
+ <string name="captcha_required">需要 CAPTCHA</string>
<string name="captcha_hint">輸入上圖中的文字</string>
+ <string name="certificate_chain_is_not_trusted">未受信任的證書鏈</string>
+ <string name="jid_does_not_match_certificate">XMPP 地址與證書不相符</string>
<string name="action_renew_certificate">更新證書</string>
<string name="error_fetching_omemo_key">獲取 OMEMO 金鑰錯誤!</string>
<string name="verified_omemo_key_with_certificate">請用證書驗證 OMEMO 金鑰!</string>
@@ -388,6 +479,7 @@
<string name="pref_use_tor_summary">所有連接使用 Tor 網路傳輸,需要 Orbot</string>
<string name="account_settings_hostname">主機名稱</string>
<string name="account_settings_port">埠</string>
+ <string name="hostname_or_onion">伺服器- 或 .orion- 地址</string>
<string name="not_a_valid_port">該埠號無效</string>
<string name="not_valid_hostname">該主機名稱無效</string>
<string name="connected_accounts">%2$d 個中的 %1$d 個帳戶已連接</string>
@@ -395,11 +487,20 @@
<item quantity="other">%d 則訊息</item>
</plurals>
<string name="load_more_messages">載入更多訊息</string>
+ <string name="shared_file_with_x">與 %s 分享的檔案</string>
+ <string name="shared_image_with_x">與 %s 分享的圖片</string>
+ <string name="shared_images_with_x">與 %s 分享的圖片</string>
+ <string name="shared_text_with_x">與 %s 分享的文字</string>
+ <string name="no_storage_permission">授予 %1$s 存取外部儲存</string>
+ <string name="no_camera_permission">授予 %1$s 存取相機</string>
<string name="sync_with_contacts">與連絡人同步</string>
<string name="notify_on_all_messages">為所有訊息顯示通知</string>
+ <string name="notify_only_when_highlighted">只在被提到時通知</string>
<string name="notify_never">關閉通知</string>
<string name="notify_paused">暫停通知</string>
+ <string name="pref_picture_compression">圖像壓縮</string>
<string name="always">總是</string>
+ <string name="large_images_only">只限大圖片</string>
<string name="battery_optimizations_enabled">啟用節電模式</string>
<string name="disable">禁用</string>
<string name="selection_too_large">選擇區域過大</string>
@@ -408,10 +509,17 @@
<string name="correct_message">更正訊息</string>
<string name="send_corrected_message">發送更正後的訊息</string>
<string name="this_account_is_disabled">你已經禁用了此帳戶</string>
+ <string name="security_error_invalid_file_access">安全性錯誤:無效的檔案存取!</string>
+ <string name="no_application_to_share_uri">找不到可以分享 URI 的應用程式</string>
<string name="share_uri_with">分享網址(URI)…</string>
- <string name="create_account">創建帳戶</string>
+ <string name="agree_and_continue">同意並繼續</string>
+ <string name="magic_create_text">此指引將爲你在conversations.im¹上建立一個賬戶。\n使用 conversations.im 為你的提供者,再將你完整的 XMPP 地址交給使用其他提供者的用戶後,你便能與他們進行交流。</string>
+ <string name="your_full_jid_will_be">您的 XMPP 完整地址將會是: %s</string>
+ <string name="create_account">建立帳戶</string>
<string name="use_own_provider">使用我自己的服務端</string>
<string name="pick_your_username">輸入您的用戶名</string>
+ <string name="pref_manually_change_presence">手動更改在線狀態</string>
+ <string name="pref_manually_change_presence_summary">在編輯你的狀態訊息時設立你的在線狀態</string>
<string name="status_message">狀態訊息</string>
<string name="presence_chat">免費聊天室</string>
<string name="presence_online">線上</string>
@@ -423,6 +531,7 @@
<string name="registration_please_wait">註冊失敗:請重試</string>
<string name="registration_password_too_weak">註冊失敗:密碼太弱</string>
<string name="choose_participants">選擇成員</string>
+ <string name="creating_conference">正在建立群組聊天...</string>
<string name="invite_again">重新邀請</string>
<string name="gp_disable">禁用</string>
<string name="gp_short">短</string>
@@ -431,6 +540,10 @@
<string name="pref_privacy">隱私</string>
<string name="pref_theme_options">主題</string>
<string name="pref_theme_options_summary">選擇調色板</string>
+ <string name="pref_theme_automatic">自動</string>
+ <string name="pref_theme_light">明亮</string>
+ <string name="pref_theme_dark">深色</string>
+ <string name="unable_to_connect_to_keychain">無法連接到 OpenKeychain</string>
<string name="this_device_is_no_longer_in_use">此設備不再使用</string>
<string name="type_pc">電腦</string>
<string name="type_phone">行動電話</string>
@@ -438,19 +551,27 @@
<string name="type_web">流覽器</string>
<string name="type_console">控制台</string>
<string name="payment_required">需要付款</string>
+ <string name="missing_internet_permission">允計互聯網存取權</string>
<string name="me">我</string>
<string name="contact_asks_for_presence_subscription">連絡人請求線上訂閱</string>
<string name="allow">允許</string>
<string name="no_permission_to_access_x">沒有訪問 %s 的許可</string>
<string name="remote_server_not_found">找不到遠端伺服器</string>
+ <string name="remote_server_timeout">遠端伺服器超時</string>
+ <string name="unable_to_update_account">無法更新帳戶</string>
+ <string name="report_jid_as_spammer">舉報此 XMPP 地址發送垃圾信息</string>
<string name="pref_delete_omemo_identities">刪除 OMEMO 身份</string>
<string name="delete_selected_keys">刪除選擇的金鑰</string>
<string name="error_publish_avatar_offline">你需要連接才能發佈頭像</string>
<string name="show_error_message">顯示錯誤訊息</string>
<string name="error_message">錯誤訊息</string>
<string name="data_saver_enabled">省流量模式已啟動</string>
+ <string name="device_does_not_support_data_saver">該設備不支援對 %1$s 禁用節省流量模式</string>
+ <string name="error_unable_to_create_temporary_file">無法建立暫存檔案</string>
<string name="this_device_has_been_verified">已經驗證這個設備了</string>
<string name="copy_fingerprint">複製指紋</string>
+ <string name="all_omemo_keys_have_been_verified">你已驗證了你擁有的所有 OMEMO 密鑰</string>
+ <string name="barcode_does_not_contain_fingerprints_for_this_conversation">條碼中沒有這個會話的指紋。</string>
<string name="verified_fingerprints">驗證過的指紋</string>
<string name="use_camera_icon_to_scan_barcode">使用相機來掃描聯絡人的條碼</string>
<string name="please_wait_for_keys_to_be_fetched">取得金鑰中,請稍後</string>
@@ -459,18 +580,38 @@
<string name="share_as_http">分享網頁連結</string>
<string name="pref_blind_trust_before_verification">在驗證前總是信任</string>
<string name="not_trusted">不可信任</string>
- <string name="invalid_barcode">二維條碼不合格</string>
+ <string name="invalid_barcode">二維條碼無效</string>
<string name="pref_clean_cache">清理快取資料</string>
<string name="pref_clean_private_storage">清理私人空間</string>
<string name="pref_clean_private_storage_summary">清理儲存檔案的私人空間(檔案還可以從伺服器重新下載)</string>
<string name="i_followed_this_link_from_a_trusted_source">我使用來源可信任的連結</string>
<string name="verifying_omemo_keys_trusted_source">點了連結以後將會驗證 %1$s 的 OMEMO 金鑰。這個行為只有在該連結的來源可信任,並且只有 %2$s 可以提供該連結的情況下,才是安全無虞的。</string>
+ <string name="continue_btn">繼續</string>
<string name="verify_omemo_keys">驗證 OMEMO 金鑰</string>
<string name="distrust_omemo_key">停止信任設備</string>
+ <plurals name="seconds">
+ <item quantity="other">%d 秒</item>
+ </plurals>
+ <plurals name="minutes">
+ <item quantity="other">%d 分鐘</item>
+ </plurals>
+ <plurals name="hours">
+ <item quantity="other">%d 小時</item>
+ </plurals>
+ <plurals name="days">
+ <item quantity="other">%d 天</item>
+ </plurals>
+ <plurals name="weeks">
+ <item quantity="other">%d 星期</item>
+ </plurals>
+ <plurals name="months">
+ <item quantity="other">%d 月</item>
+ </plurals>
<string name="pref_automatically_delete_messages">自動刪除訊息</string>
<string name="pref_automatically_delete_messages_description">自動從這個設備刪除比設定的時間區間還舊的訊息。</string>
<string name="encrypting_message">訊息加密中</string>
<string name="not_fetching_history_retention_period">訊息的時間因為超過本機保留區間而沒有下載。</string>
+ <string name="transcoding_video">壓縮影片中</string>
<string name="corresponding_conversations_closed">關閉相關的對話了。</string>
<string name="contact_blocked_past_tense">已經封鎖聯絡人了。</string>
<string name="pref_notifications_from_strangers">陌生人訊息通知</string>
@@ -480,9 +621,17 @@
<string name="online_right_now">剛剛上線了</string>
<string name="retry_decryption">再試解密ㄧ次</string>
<string name="session_failure">通訊對話錯誤</string>
+ <string name="sasl_downgrade">已降級的 SASL 機制</string>
+ <string name="account_status_regis_web">伺服器要求在網站上註冊</string>
+ <string name="open_website">開啟網站</string>
+ <string name="application_found_to_open_website">沒有可以打開網站的應用程式</string>
<string name="pref_headsup_notifications">頭條通知</string>
+ <string name="pref_headsup_notifications_summary">顯示頭條通知</string>
<string name="today">今天</string>
<string name="yesterday">昨天</string>
+ <string name="pref_validate_hostname">以 DNSSEC 驗證主機名稱</string>
+ <string name="certificate_does_not_contain_jid">證書不包含 XMPP 地址</string>
+ <string name="server_info_partial">部份</string>
<string name="attach_record_video">錄製影片</string>
<string name="copy_to_clipboard">複製到剪貼簿</string>
<string name="message_copied_to_clipboard">訊息已複製到剪貼簿</string>
@@ -490,8 +639,12 @@
<string name="private_messages_are_disabled">私密訊息已停用</string>
<string name="huawei_protected_apps">受保護的應用程式</string>
<string name="mtm_accept_cert">接受未知憑證?</string>
+ <string name="mtm_trust_anchor">伺服器證書未由已知證書機構簽發</string>
+ <string name="mtm_accept_servername">接受不相符的伺服器名稱?</string>
+ <string name="mtm_connect_anyway">你仍然想連線嗎?</string>
<string name="mtm_cert_details">憑證詳細資料:</string>
<string name="once">僅一次</string>
+ <string name="qr_code_scanner_needs_access_to_camera">二維條碼掃描器需要相機權限</string>
<string name="pref_scroll_to_bottom">捲動至底部</string>
<string name="pref_scroll_to_bottom_summary">傳送訊息後向下捲動</string>
<string name="edit_status_message_title">編輯狀態訊息</string>
@@ -499,7 +652,9 @@
<string name="disable_encryption">停用加密</string>
<string name="error_trustkey_device_list">無法擷取裝置清單</string>
<string name="error_trustkey_bundle">無法擷取加密金鑰</string>
+ <string name="error_trustkey_hint_mutual">提示:某些情況下,將對方加入聯絡人列表,便可以解決此問題。</string>
<string name="disable_now">立即停用</string>
+ <string name="draft">草稿:</string>
<string name="pref_omemo_setting">OMEMO 加密</string>
<string name="pref_omemo_setting_summary_always">一對一以及私人群組的聊天一定會用 OMEMO</string>
<string name="pref_omemo_setting_summary_default_on">新的對話預設會用 OMEMO 加密</string>
@@ -512,6 +667,8 @@
<string name="small">小</string>
<string name="medium">中</string>
<string name="large">大</string>
+ <string name="not_encrypted_for_this_device">訊息未在此裝置加密</string>
+ <string name="omemo_decryption_failed">OMEMO 訊息解密失敗</string>
<string name="undo">復原</string>
<string name="location_disabled">位置分享已停用</string>
<string name="action_fix_to_location">固定位置</string>
@@ -532,71 +689,246 @@
<string name="pref_use_share_location_plugin_summary">使用分享位置外掛程式而非內建地圖</string>
<string name="copy_link">複製網站位址</string>
<string name="copy_jabber_id">複製 XMPP 位址</string>
+ <string name="p1_s3_filetransfer">用於 S3 的 HTTP 檔案分享</string>
<string name="pref_start_search">直接搜尋</string>
+ <string name="pref_start_search_summary">在「開始對話」版面上打開鍵盤並將遊標放在搜尋列</string>
+ <string name="group_chat_avatar">群組聊天頭像</string>
+ <string name="host_does_not_support_group_chat_avatars">主機不支援群組聊天頭像</string>
+ <string name="only_the_owner_can_change_group_chat_avatar">只有擁有者才能變更群組聊天頭像</string>
+ <string name="contact_name">聯絡人名稱</string>
<string name="nickname">暱稱</string>
<string name="group_chat_name">名稱</string>
+ <string name="providing_a_name_is_optional">可選擇提供名稱</string>
<string name="create_dialog_group_chat_name">聊天群組名稱</string>
+ <string name="conference_destroyed">此群組聊天已被解散</string>
<string name="unable_to_save_recording">無法儲存錄製</string>
+ <string name="foreground_service_channel_name">前臺服務</string>
+ <string name="foreground_service_channel_description">此通知類別用於顯示 %1$s 正在運行永久通知。</string>
<string name="notification_group_status_information">狀態資訊</string>
+ <string name="error_channel_name">連接問題</string>
+ <string name="error_channel_description">此通知類別用於顯示帳戶連接問題的通知。</string>
<string name="notification_group_messages">訊息 </string>
<string name="notification_group_calls">通話</string>
<string name="messages_channel_name">訊息</string>
<string name="incoming_calls_channel_name">來電</string>
<string name="ongoing_calls_channel_name">正在進行的通話</string>
+ <string name="missed_calls_channel_name">未接來電</string>
<string name="silent_messages_channel_name">無聲訊息</string>
+ <string name="delivery_failed_channel_name">傳送失敗</string>
<string name="pref_message_notification_settings">訊息通知設定</string>
<string name="pref_incoming_call_notification_settings">來電通知設定</string>
+ <string name="pref_more_notification_settings_summary">重要程度,聲音,振動</string>
<string name="video_compression_channel_name">影片壓縮</string>
<string name="view_media">檢視媒體</string>
<string name="group_chat_members">成員</string>
<string name="media_browser">媒體瀏覽器</string>
+ <string name="security_violation_not_attaching_file">由於違反安全規定,你的檔案已被刪除。</string>
<string name="pref_video_compression">影片質量</string>
<string name="pref_video_compression_summary">低質量意味這更小的檔案</string>
<string name="video_360p">中 (360P)</string>
<string name="video_720p">高 (720P)</string>
<string name="cancelled">已取消</string>
+ <string name="already_drafting_message">你已經在起草一條訊息。</string>
+ <string name="feature_not_implemented">沒有此功能</string>
<string name="invalid_country_code">無效的國家碼</string>
<string name="choose_a_country">選擇國家</string>
<string name="phone_number">電話號碼</string>
<string name="verify_your_phone_number">驗證電話號碼</string>
+ <string name="enter_country_code_and_phone_number">Quicksy 將發送短訊(營運商可能收費)以驗證你的電話號碼。輸入國家地區代碼和手機號碼:</string>
+ <string name="not_a_valid_phone_number">%s 不是有效的電話號碼</string>
<string name="please_enter_your_phone_number">請輸入您的電話號碼。</string>
<string name="search_countries">搜尋國家</string>
<string name="verify_x">驗證 %s</string>
+ <string name="we_have_sent_you_an_sms_to_x"><![CDATA[我們已將你的短訊傳送到 <b>%s</b>。]]></string>
+ <string name="we_have_sent_you_another_sms">我們已向你發出另一個包含六位數字代碼的簡訊。</string>
+ <string name="please_enter_pin_below">請在下面輸入六位數字的 PIN 碼。</string>
<string name="resend_sms">重新傳送簡訊</string>
<string name="resend_sms_in">重新傳送簡訊 (%s)</string>
<string name="wait_x">請等候 (%s)</string>
<string name="back">返回</string>
+ <string name="possible_pin">已自動從剪貼簿貼上可能的 PIN 碼</string>
+ <string name="please_enter_pin">請輸入六位數字的 PIN 碼。</string>
+ <string name="abort_registration_procedure">你確定要終止註冊?</string>
<string name="yes">是</string>
<string name="no">否</string>
<string name="verifying">正在驗證…</string>
<string name="requesting_sms">正在要求簡訊…</string>
+ <string name="incorrect_pin">你輸入的 PIN 碼不正確。</string>
+ <string name="pin_expired">我們向你發出的 PIN 碼已經過期。</string>
<string name="unknown_api_error_network">未知網路錯誤。</string>
+ <string name="unknown_api_error_response">伺服器的未知回應。</string>
+ <string name="unable_to_connect_to_server">無法與伺服器連接。</string>
+ <string name="unable_to_establish_secure_connection">無法建立安全連線。</string>
+ <string name="unable_to_find_server">找不到伺服器</string>
+ <string name="something_went_wrong_processing_your_request">處理你的請求時出錯</string>
+ <string name="invalid_user_input">無效的用戶輸入</string>
+ <string name="temporarily_unavailable">暫時無法連接,請稍候再試。</string>
<string name="no_network_connection">沒有網路連線。</string>
+ <string name="try_again_in_x">請在 %s 後再次嘗試</string>
+ <string name="rate_limited">你的頻率已被限制</string>
+ <string name="too_many_attempts">太多的嘗試</string>
+ <string name="the_app_is_out_of_date">你正在使用此應用程式的過時版本。</string>
<string name="update">更新</string>
+ <string name="logged_in_with_another_device">此電話號碼已在其他裝置上登錄</string>
+ <string name="enter_your_name_instructions">請輸入您的名稱,使那些沒有把你加入通訊錄的人也知道你是誰。</string>
<string name="your_name">你的名稱</string>
<string name="enter_your_name">輸入你的名稱</string>
+ <string name="no_name_set_instructions">用編輯按鍵設立你的名稱</string>
<string name="reject_request">拒絕要求</string>
+ <string name="install_orbot">安裝 Orbot</string>
+ <string name="start_orbot">啟動 Orbot</string>
+ <string name="no_market_app_installed">沒有安裝軟件商店</string>
+ <string name="group_chat_will_make_your_jabber_id_public">這頻道將會公開你的 XMPP 地址</string>
<string name="ebook">電子書</string>
+ <string name="video_original">原始(未壓縮)</string>
<string name="open_with">開啟為…</string>
+ <string name="set_profile_picture">Conversations 設定檔圖片</string>
<string name="choose_account">選擇帳戶</string>
<string name="restore_backup">還原備份</string>
<string name="restore">還原</string>
+ <string name="enter_password_to_restore">輸入帳戶 %s 的密碼以恢復備份。</string>
+ <string name="restore_warning">請勿使用恢復備份功能來嘗試複製安裝(即同時運行)。恢復備份功能應只在遷移裝置或丟失裝置的情況下才使用。</string>
+ <string name="unable_to_restore_backup">無法恢復備份。</string>
+ <string name="unable_to_decrypt_backup">無法為備份解密。密碼是不正確?</string>
<string name="backup_channel_name">備份與還原</string>
+ <string name="enter_jabber_id">輸入 XMPP 地址</string>
<string name="create_group_chat">建立群組聊天</string>
<string name="join_public_channel">加入公用頻道</string>
<string name="create_private_group_chat">建立私人群組聊天</string>
<string name="create_public_channel">建立公用頻道</string>
<string name="create_dialog_channel_name">頻道名稱</string>
<string name="xmpp_address">XMPP 位址</string>
+ <string name="please_enter_name">請為頻道提供一個名稱</string>
+ <string name="please_enter_xmpp_address">請提供 XMPP 地址</string>
+ <string name="this_is_an_xmpp_address">這是一個 XMPP 地址。請提供名稱。</string>
+ <string name="creating_channel">正在建立公用頻道...</string>
+ <string name="channel_already_exists">此頻道已經存在</string>
+ <string name="joined_an_existing_channel">你已加入一個已經存在的頻道廿</string>
+ <string name="unable_to_set_channel_configuration">無法儲存頻道設置</string>
+ <string name="allow_participants_to_edit_subject">允許所有人編輯主題</string>
+ <string name="allow_participants_to_invite_others">允許所有人邀請其他人</string>
+ <string name="anyone_can_edit_subject">所有人都可以編輯主題</string>
+ <string name="owners_can_edit_subject">擁有人可以編輯主題</string>
+ <string name="admins_can_edit_subject">管理員可以編輯主題</string>
+ <string name="owners_can_invite_others">擁有人可以邀請其他人</string>
+ <string name="anyone_can_invite_others">所有人都可以邀請其他人</string>
+ <string name="jabber_ids_are_visible_to_admins">管理員可以看見此 XMPP 地址</string>
+ <string name="jabber_ids_are_visible_to_anyone">所有人可以看見 XMPP 地址</string>
+ <string name="no_users_hint_channel">此公開頻道沒有成員。邀請聯絡人或使用分享按鍵傳播 XMPP 地址。</string>
+ <string name="no_users_hint_group_chat">此私人群組聊天沒有成員</string>
+ <string name="manage_permission">管理許可權</string>
+ <string name="search_participants">搜尋成員</string>
+ <string name="file_too_large">檔案太大</string>
+ <string name="attach">附加</string>
+ <string name="discover_channels">探索頻道</string>
+ <string name="search_channels">搜尋頻道</string>
+ <string name="channel_discovery_opt_in_title">可能侵犯私隱!</string>
+ <string name="channel_discover_opt_in_message"><![CDATA[頻道探索使用了名爲<a href=\"https://search.jabber.network\">search.jabber.network</a><br><br>的第三方服務。使用此功能會將你的IP地址和搜尋字詞傳輸到該服務。 有關更多資訊,請參閱其<a href=\"https://search.jabber.network/privacy\">私隱政策</a>。]]></string>
+ <string name="i_already_have_an_account">我已經有一個帳戶</string>
+ <string name="add_existing_account">添加已有帳戶</string>
+ <string name="register_new_account">註冊新帳戶</string>
+ <string name="this_looks_like_a_domain">這看似是一個網域地址</string>
+ <string name="add_anway">仍然添加</string>
+ <string name="this_looks_like_channel">這看似是一個頻道地址</string>
+ <string name="share_backup_files">分享備份檔案</string>
+ <string name="conversations_backup">Conversations 備份</string>
<string name="event">活動</string>
<string name="open_backup">開啟備份</string>
+ <string name="not_a_backup_file">你選擇的並不是 Conversations 的備份檔案</string>
+ <string name="account_already_setup">此帳戶已設置</string>
+ <string name="please_enter_password">請輸入此帳戶的密碼</string>
+ <string name="unable_to_perform_this_action">無法執行此操作</string>
+ <string name="open_join_dialog">加入公用頻道...</string>
+ <string name="sharing_application_not_grant_permission">分享程式沒有存取檔案的權限</string>
+ <string name="group_chats_and_channels"><![CDATA[群組聊天 & 頻道]]> </string>
<string name="local_server">本機伺服器</string>
+ <string name="pref_channel_discovery_summary">大多數用戶應該選擇 “jabber.network” 以從整個公開的 XMPP 生態系統中獲得更好的建議。</string>
+ <string name="pref_channel_discovery">頻道探索方法</string>
+ <string name="backup">備份</string>
<string name="category_about">關於</string>
+ <string name="please_enable_an_account">請啟用一個帳戶</string>
+ <string name="make_call">進行通話</string>
+ <string name="rtp_state_incoming_call">來電</string>
+ <string name="rtp_state_incoming_video_call">視像通話來電</string>
+ <string name="rtp_state_connecting">正在連接</string>
+ <string name="rtp_state_connected">已接通</string>
+ <string name="rtp_state_reconnecting">正在重新連接</string>
+ <string name="rtp_state_accepting_call">正在接通來電</string>
+ <string name="rtp_state_ending_call">終止通話</string>
+ <string name="answer_call">接聽</string>
+ <string name="dismiss_call">拒接</string>
+ <string name="rtp_state_finding_device">正在探索裝置</string>
+ <string name="rtp_state_ringing">正在響鈴</string>
<string name="rtp_state_declined_or_busy">忙碌</string>
+ <string name="rtp_state_connectivity_error">無法連接通話</string>
+ <string name="rtp_state_connectivity_lost_error">連接失敗</string>
+ <string name="rtp_state_retracted">通話已撤銷</string>
+ <string name="rtp_state_application_failure">程式錯誤</string>
+ <string name="rtp_state_security_error">驗証問題</string>
+ <string name="hang_up">掛斷</string>
+ <string name="ongoing_call">正在進行的通話</string>
+ <string name="ongoing_video_call">打出視像通話</string>
+ <string name="reconnecting_call">重新連接通話</string>
+ <string name="reconnecting_video_call">視像通話重新連接中</string>
+ <string name="disable_tor_to_make_call">關閉 Tor 以進行通話</string>
+ <string name="incoming_call">來電</string>
+ <string name="incoming_call_duration">來電 %s</string>
+ <string name="missed_call_timestamp">未接來電 %s</string>
+ <string name="outgoing_call">撥出通話</string>
+ <string name="outgoing_call_duration">撥出通話 %s</string>
+ <string name="missed_call">未接來電</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="other">來自 %2$s 的 %1$d 個未接來電</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="other">%d 未接來電</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="other">來自 %2$d 個聯絡人的 %1$d 個未接來電</item>
+ </plurals>
+ <string name="audio_call">語音通話</string>
+ <string name="video_call">視像通話</string>
<string name="help">說明</string>
+ <string name="switch_to_conversation">切換到會話</string>
+ <string name="microphone_unavailable">你的麥克風未能使用</string>
+ <string name="only_one_call_at_a_time">你同時只能有一個通話</string>
+ <string name="return_to_ongoing_call">返回正在進行的通話</string>
+ <string name="could_not_switch_camera">無法切換鏡頭</string>
<string name="add_to_favorites">釘選</string>
<string name="remove_from_favorites">取消釘選</string>
+ <string name="gpx_track">GPX 追綜</string>
+ <string name="could_not_correct_message">無法更正訊息</string>
+ <string name="search_all_conversations">所有會話</string>
+ <string name="search_this_conversation">這會話</string>
+ <string name="your_avatar">你的頭像</string>
+ <string name="avatar_for_x">%s 的頭像</string>
+ <string name="encrypted_with_omemo">以 OMEMO 加密</string>
+ <string name="encrypted_with_openpgp">以 OpenPGP 加密</string>
+ <string name="not_encrypted">沒有加密</string>
<string name="exit">離開</string>
+ <string name="record_voice_mail">錄製語音訊息</string>
<string name="play_audio">播放音訊</string>
+ <string name="pause_audio">暫停音訊</string>
+ <string name="add_contact_or_create_or_join_group_chat">添加聯絡人, 建立或加入群組聊天, 或探索頻道</string>
+ <plurals name="view_users">
+ <item quantity="other">查看 %1$d 成員</item>
+ </plurals>
+ <plurals name="some_messages_could_not_be_delivered">
+ <item quantity="other">有些訊息無法傳送</item>
+ </plurals>
+ <string name="failed_deliveries">傳送失敗</string>
<string name="more_options">更多選項</string>
+ <string name="no_application_found">沒有找到應用程式</string>
+ <string name="invite_to_app">邀請到 Conversations</string>
+ <string name="unable_to_parse_invite">無法解析邀請</string>
+ <string name="server_does_not_support_easy_onboarding_invites">伺服器不支援產生邀請</string>
+ <string name="no_active_accounts_support_this">沒有活躍帳戶支持此功能</string>
+ <string name="backup_started_message">已開始進行備份。完成後你會收到一則通知。</string>
+ <string name="unable_to_enable_video">無法啓用視訊</string>
+ <string name="plain_text_document">純文字檔案</string>
+ <string name="account_registrations_are_not_supported">不支援帳戶註冊</string>
+ <string name="no_xmpp_adddress_found">未找到 XMPP 地址</string>
+ <string name="account_status_temporary_auth_failure">臨時驗證失敗</string>
+ <string name="delete_avatar">刪除頭像</string>
+ <string name="audio_video_disabled_tor">使用 Tor 時不能進行通話</string>
</resources>
@@ -169,6 +169,7 @@
<string name="account_status_tls_error_domain">Domain not verifiable</string>
<string name="account_status_policy_violation">Policy violation</string>
<string name="account_status_incompatible_server">Incompatible server</string>
+ <string name="account_status_incompatible_client">Incompatible client</string>
<string name="account_status_stream_error">Stream error</string>
<string name="account_status_stream_opening_error">Stream opening error</string>
<string name="encryption_choice_unencrypted">TLS</string>
@@ -293,8 +294,8 @@
<string name="title_pref_enable_quiet_hours">Enable quiet hours</string>
<string name="pref_quiet_hours_summary">Notifications will be silenced during quiet hours</string>
<string name="pref_expert_options_other">Other</string>
- <string name="pref_autojoin">Synchronize with bookmarks</string>
- <string name="pref_autojoin_summary">Join group chats automatically if the bookmark says so</string>
+ <string name="pref_autojoin">Synchronize bookmarks</string>
+ <string name="pref_autojoin_summary">Set “autojoin” flag when entering or leaving a MUC and react to modifications made by other clients.</string>
<string name="toast_message_omemo_fingerprint">OMEMO fingerprint copied to clipboard</string>
<string name="conference_banned">You are banned from this group chat</string>
<string name="conference_members_only">This group chat is members only</string>
@@ -302,6 +303,7 @@
<string name="conference_kicked">You have been kicked from this group chat</string>
<string name="conference_shutdown">The group chat was shut down</string>
<string name="conference_unknown_error">You are no longer in this group chat</string>
+ <string name="conference_technical_problems">You left this group chat due to technical reasons</string>
<string name="using_account">using account %s</string>
<string name="hosted_on">hosted on %s</string>
<string name="checking_x">Checking %s on HTTP host</string>
@@ -910,6 +912,8 @@
<string name="make_call">Make call</string>
<string name="rtp_state_incoming_call">Incoming call</string>
<string name="rtp_state_incoming_video_call">Incoming video call</string>
+ <string name="rtp_state_content_add_video">Switch to video call?</string>
+ <string name="rtp_state_content_add">Add additional tracks?</string>
<string name="rtp_state_connecting">Connecting</string>
<string name="rtp_state_connected">Connected</string>
<string name="rtp_state_reconnecting">Reconnecting</string>
@@ -941,10 +945,18 @@
<string name="outgoing_call_duration">Outgoing call (%s)</string>
<string name="outgoing_call_duration_timestamp">Outgoing call (%s) . %s</string>
<string name="missed_call">Missed call</string>
- <string name="missed_call_from_x">Missed call from %s</string>
- <string name="n_missed_calls_from_x">%1$d missed calls from %2$s</string>
- <string name="n_missed_calls">%d missed calls</string>
- <string name="n_missed_calls_from_m_contacts">%1$d missed calls from %2$d contacts</string>
+ <plurals name="n_missed_calls_from_x">
+ <item quantity="one">%1$d missed call from %2$s</item>
+ <item quantity="other">%1$d missed calls from %2$s</item>
+ </plurals>
+ <plurals name="n_missed_calls">
+ <item quantity="one">%d missed call</item>
+ <item quantity="other">%d missed calls</item>
+ </plurals>
+ <plurals name="n_missed_calls_from_m_contacts">
+ <item quantity="one">%1$d missed calls from %2$d contact</item>
+ <item quantity="other">%1$d missed calls from %2$d contacts</item>
+ </plurals>
<string name="audio_call">Audio call</string>
<string name="video_call">Video call</string>
<string name="help">Help</string>
@@ -991,5 +1003,9 @@
<string name="no_xmpp_adddress_found">No Jabber ID found</string>
<string name="account_status_temporary_auth_failure">Temporary authentication failure</string>
<string name="microphone_permission_for_call">Microphone permission required to complete call</string>
+ <string name="delete_avatar">Delete avatar</string>
+ <string name="audio_video_disabled_tor">Calls are disabled when using Tor</string>
+ <string name="switch_to_video">Switch to video</string>
+ <string name="reject_switch_to_video">Reject switch to video request</string>
</resources>
@@ -15,7 +15,8 @@
android:targetPackage="com.huawei.systemmanager" />
</PreferenceScreen>
</PreferenceCategory>
- <PreferenceCategory android:title="@string/pref_privacy">
+ <PreferenceCategory android:title="@string/pref_privacy"
+ android:key="privacy">
<CheckBoxPreference
android:defaultValue="@bool/confirm_messages"
android:key="confirm_messages"
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
+ <string name="pref_notification_grace_period_summary">發現在其它設備上的活動後,Quicksy 保持安靜的時間</string>
+ <string name="pref_never_send_crash_summary">發送堆疊跟蹤説明以幫助 Quicksy 持續開發</string>
+ <string name="pref_broadcast_last_activity_summary">讓你的所有聯絡人知道你何時使用 Quicksy</string>
+ <string name="huawei_protected_apps_summary">爲了在螢幕關閉時也能收到通知,你需要將 Quicksy 加入受保護的應用程式列表。</string>
<string name="set_profile_picture">Quicksy 設定檔圖片</string>
<string name="not_available_in_your_country">Quicksy 在您的國家無法使用。</string>
<string name="unable_to_verify_server_identity">無法驗證伺服器身分。</string>