diff --git a/CHANGELOG.md b/CHANGELOG.md index 009e193ed..a363926b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Version 2.8.6 + +* Offer to record voice message when callee is busy + ### Version 2.8.5 * Reduce echo during calls on some devices diff --git a/README.md b/README.md index e43c9fe4b..da7166cb5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ * End-to-end encryption with [OMEMO](http://conversations.im/omemo/) or [OpenPGP](http://openpgp.org/about/) * Send and receive images as well as other kind of files -* Encrypted audio and video calls (DLTS-SRTP) +* Encrypted audio and video calls (DTLS-SRTP) * Share your location * Send voice messages * Indication when your contact has read your message diff --git a/build.gradle b/build.gradle index 2bdfc67c3..40ba513ae 100644 --- a/build.gradle +++ b/build.gradle @@ -96,8 +96,8 @@ android { defaultConfig { minSdkVersion 16 targetSdkVersion 25 - versionCode 388 - versionName "2.8.5" + versionCode 390 + versionName "2.8.6" archivesBaseName += "-$versionName" applicationId "eu.sum7.conversations" resValue "string", "applicationId", applicationId diff --git a/metadata/en-US/changelogs/390.txt b/metadata/en-US/changelogs/390.txt new file mode 100644 index 000000000..f22ea34cd --- /dev/null +++ b/metadata/en-US/changelogs/390.txt @@ -0,0 +1 @@ +• Offer to record voice message when callee is busy diff --git a/screenshots.xcf b/screenshots.xcf index 7e3aebc68..45f79ca5a 100644 Binary files a/screenshots.xcf and b/screenshots.xcf differ diff --git a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java index 6949daec4..4d40d2b74 100644 --- a/src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java +++ b/src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java @@ -195,6 +195,10 @@ 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"); + } + for (int i = 0; i < clientProof.length; i++) { clientProof[i] = (byte) (keys.clientKey[i] ^ clientSignature[i]); } diff --git a/src/main/java/eu/siacs/conversations/entities/Account.java b/src/main/java/eu/siacs/conversations/entities/Account.java index d2d25e321..4b7e424a3 100644 --- a/src/main/java/eu/siacs/conversations/entities/Account.java +++ b/src/main/java/eu/siacs/conversations/entities/Account.java @@ -432,7 +432,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable public int activeDevicesWithRtpCapability() { int i = 0; - for(Presence presence : getSelfContact().getPresences().getPresences().values()) { + for(Presence presence : getSelfContact().getPresences().getPresences()) { if (RtpCapability.check(presence) != RtpCapability.Capability.NONE) { i++; } diff --git a/src/main/java/eu/siacs/conversations/entities/Presences.java b/src/main/java/eu/siacs/conversations/entities/Presences.java index c8301123b..feca98839 100644 --- a/src/main/java/eu/siacs/conversations/entities/Presences.java +++ b/src/main/java/eu/siacs/conversations/entities/Presences.java @@ -9,138 +9,164 @@ import java.util.List; import java.util.Map; public class Presences { - private final Hashtable presences = new Hashtable<>(); + private final Hashtable presences = new Hashtable<>(); - public Hashtable getPresences() { - return this.presences; - } + private static String nameWithoutVersion(String name) { + String[] parts = name.split(" "); + if (parts.length > 1 && Character.isDigit(parts[parts.length - 1].charAt(0))) { + StringBuilder output = new StringBuilder(); + for (int i = 0; i < parts.length - 1; ++i) { + if (output.length() != 0) { + output.append(' '); + } + output.append(parts[i]); + } + return output.toString(); + } else { + return name; + } + } - public void updatePresence(String resource, Presence presence) { - synchronized (this.presences) { - this.presences.put(resource, presence); - } - } + public List getPresences() { + synchronized (this.presences) { + return new ArrayList<>(this.presences.values()); + } + } - public void removePresence(String resource) { - synchronized (this.presences) { - this.presences.remove(resource); - } - } + public Map getPresencesMap() { + synchronized (this.presences) { + return new HashMap<>(this.presences); + } + } - public void clearPresences() { - synchronized (this.presences) { - this.presences.clear(); - } - } + public Presence get(String resource) { + synchronized (this.presences) { + return this.presences.get(resource); + } + } - public Presence.Status getShownStatus() { - Presence.Status status = Presence.Status.OFFLINE; - synchronized (this.presences) { - for(Presence p : presences.values()) { - if (p.getStatus() == Presence.Status.DND) { - return p.getStatus(); - } else if (p.getStatus().compareTo(status) < 0){ - status = p.getStatus(); - } - } - } - return status; - } + public void updatePresence(String resource, Presence presence) { + synchronized (this.presences) { + this.presences.put(resource, presence); + } + } - public int size() { - synchronized (this.presences) { - return presences.size(); - } - } + public void removePresence(String resource) { + synchronized (this.presences) { + this.presences.remove(resource); + } + } - public String[] toResourceArray() { - synchronized (this.presences) { - final String[] presencesArray = new String[presences.size()]; - presences.keySet().toArray(presencesArray); - return presencesArray; - } - } + public void clearPresences() { + synchronized (this.presences) { + this.presences.clear(); + } + } - public List asTemplates() { - synchronized (this.presences) { - ArrayList templates = new ArrayList<>(presences.size()); - for(Presence p : presences.values()) { - if (p.getMessage() != null && !p.getMessage().trim().isEmpty()) { - templates.add(new PresenceTemplate(p.getStatus(), p.getMessage())); - } - } - return templates; - } - } + public Presence.Status getShownStatus() { + Presence.Status status = Presence.Status.OFFLINE; + synchronized (this.presences) { + for (Presence p : presences.values()) { + if (p.getStatus() == Presence.Status.DND) { + return p.getStatus(); + } else if (p.getStatus().compareTo(status) < 0) { + status = p.getStatus(); + } + } + } + return status; + } - public boolean has(String presence) { - synchronized (this.presences) { - return presences.containsKey(presence); - } - } + public int size() { + synchronized (this.presences) { + return presences.size(); + } + } - public List getStatusMessages() { - ArrayList messages = new ArrayList<>(); - synchronized (this.presences) { - for(Presence presence : this.presences.values()) { - String message = presence.getMessage() == null ? null : presence.getMessage().trim(); - if (message != null && !message.isEmpty() && !messages.contains(message)) { - messages.add(message); - } - } - } - return messages; - } + public String[] toResourceArray() { + synchronized (this.presences) { + final String[] presencesArray = new String[presences.size()]; + presences.keySet().toArray(presencesArray); + return presencesArray; + } + } - public boolean allOrNonSupport(String namespace) { - synchronized (this.presences) { - for(Presence presence : this.presences.values()) { - ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult(); - if (disco == null || !disco.getFeatures().contains(namespace)) { - return false; - } - } - } - return true; - } + public List asTemplates() { + synchronized (this.presences) { + ArrayList templates = new ArrayList<>(presences.size()); + for (Presence p : presences.values()) { + if (p.getMessage() != null && !p.getMessage().trim().isEmpty()) { + templates.add(new PresenceTemplate(p.getStatus(), p.getMessage())); + } + } + return templates; + } + } - public Pair,Map> toTypeAndNameMap() { - Map typeMap = new HashMap<>(); - Map nameMap = new HashMap<>(); - synchronized (this.presences) { - for(Map.Entry presenceEntry : this.presences.entrySet()) { - String resource = presenceEntry.getKey(); - Presence presence = presenceEntry.getValue(); - ServiceDiscoveryResult serviceDiscoveryResult = presence == null ? null : presence.getServiceDiscoveryResult(); - if (serviceDiscoveryResult != null && serviceDiscoveryResult.getIdentities().size() > 0) { - ServiceDiscoveryResult.Identity identity = serviceDiscoveryResult.getIdentities().get(0); - String type = identity.getType(); - String name = identity.getName(); - if (type != null) { - typeMap.put(resource,type); - } - if (name != null) { - nameMap.put(resource, nameWithoutVersion(name)); - } - } - } - } - return new Pair<>(typeMap,nameMap); - } + public boolean has(String presence) { + synchronized (this.presences) { + return presences.containsKey(presence); + } + } - private static String nameWithoutVersion(String name) { - String[] parts = name.split(" "); - if (parts.length > 1 && Character.isDigit(parts[parts.length -1].charAt(0))) { - StringBuilder output = new StringBuilder(); - for(int i = 0; i < parts.length -1; ++i) { - if (output.length() != 0) { - output.append(' '); - } - output.append(parts[i]); - } - return output.toString(); - } else { - return name; - } - } + public List getStatusMessages() { + ArrayList messages = new ArrayList<>(); + synchronized (this.presences) { + for (Presence presence : this.presences.values()) { + String message = presence.getMessage() == null ? null : presence.getMessage().trim(); + if (message != null && !message.isEmpty() && !messages.contains(message)) { + messages.add(message); + } + } + } + return messages; + } + + public boolean allOrNonSupport(String namespace) { + synchronized (this.presences) { + for (Presence presence : this.presences.values()) { + ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult(); + if (disco == null || !disco.getFeatures().contains(namespace)) { + return false; + } + } + } + return true; + } + + public boolean anySupport(final String namespace) { + synchronized (this.presences) { + for (Presence presence : this.presences.values()) { + ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult(); + if (disco != null && disco.getFeatures().contains(namespace)) { + return true; + } + } + } + return false; + } + + public Pair, Map> toTypeAndNameMap() { + Map typeMap = new HashMap<>(); + Map nameMap = new HashMap<>(); + synchronized (this.presences) { + for (Map.Entry presenceEntry : this.presences.entrySet()) { + String resource = presenceEntry.getKey(); + Presence presence = presenceEntry.getValue(); + ServiceDiscoveryResult serviceDiscoveryResult = presence == null ? null : presence.getServiceDiscoveryResult(); + if (serviceDiscoveryResult != null && serviceDiscoveryResult.getIdentities().size() > 0) { + ServiceDiscoveryResult.Identity identity = serviceDiscoveryResult.getIdentities().get(0); + String type = identity.getType(); + String name = identity.getName(); + if (type != null) { + typeMap.put(resource, type); + } + if (name != null) { + nameMap.put(resource, nameWithoutVersion(name)); + } + } + } + } + return new Pair<>(typeMap, nameMap); + } } diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java index 7b034a255..7c98db35d 100644 --- a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java +++ b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java @@ -14,6 +14,8 @@ import android.os.IBinder; import android.support.v4.app.NotificationCompat; import android.util.Log; +import com.google.common.base.Strings; + import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -70,7 +72,7 @@ public class ExportBackupService extends Service { if (Compatibility.runsAndTargetsTwentyFour(context)) { openIntent.setType("resource/folder"); } else { - openIntent.setDataAndType(Uri.parse("file://"+path),"resource/folder"); + openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder"); } openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path); @@ -87,7 +89,7 @@ public class ExportBackupService extends Service { } - private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) { + private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) { final StringBuilder builder = new StringBuilder(); final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null); while (accountCursor != null && accountCursor.moveToNext()) { @@ -136,27 +138,28 @@ public class ExportBackupService extends Service { } } - public static byte[] getKey(String password, byte[] salt) { + public static byte[] getKey(final String password, final byte[] salt) throws InvalidKeySpecException { + final SecretKeyFactory factory; try { - SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded(); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new AssertionError(e); + factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); } + return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded(); } - private static String cursorToString(String tablename, Cursor cursor, int max) { - return cursorToString(tablename, cursor, max, false); + private static String cursorToString(final String table, final Cursor cursor, final int max) { + return cursorToString(table, cursor, max, false); } - private static String cursorToString(final String tablename, final Cursor cursor, int max, boolean ignore) { - final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(tablename); + private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) { + final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table); StringBuilder builder = new StringBuilder(); builder.append("INSERT "); if (ignore) { builder.append("OR IGNORE "); } - builder.append("INTO ").append(tablename).append("("); + builder.append("INTO ").append(table).append("("); int skipColumn = -1; for (int i = 0; i < cursor.getColumnCount(); ++i) { final String name = cursor.getColumnName(i); @@ -222,7 +225,8 @@ public class ExportBackupService extends Service { try { files = export(); success = true; - } catch (Exception e) { + } catch (final Exception e) { + Log.d(Config.LOGTAG, "unable to create backup", e); success = false; files = Collections.emptyList(); } @@ -234,6 +238,8 @@ public class ExportBackupService extends Service { stopSelf(); }).start(); return START_STICKY; + } else { + Log.d(Config.LOGTAG, "ExportBackupService. ignoring start command because already running"); } return START_NOT_STICKY; } @@ -241,7 +247,7 @@ public class ExportBackupService extends Service { private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) { Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid}); int size = cursor != null ? cursor.getCount() : 0; - Log.d(Config.LOGTAG, "exporting " + size + " messages"); + Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid); int i = 0; int p = 0; while (cursor != null && cursor.moveToNext()) { @@ -272,7 +278,14 @@ public class ExportBackupService extends Service { final int max = this.mAccounts.size(); final SecureRandom secureRandom = new SecureRandom(); final List files = new ArrayList<>(); - for (Account account : this.mAccounts) { + Log.d(Config.LOGTAG, "starting backup for " + max + " accounts"); + for (final Account account : this.mAccounts) { + final String password = account.getPassword(); + if (Strings.nullToEmpty(password).trim().isEmpty()) { + Log.d(Config.LOGTAG, String.format("skipping backup for %s because password is empty. unable to encrypt", account.getJid().asBareJid())); + continue; + } + Log.d(Config.LOGTAG, String.format("exporting data for account %s (%s)", account.getJid().asBareJid(), account.getUuid())); final byte[] IV = new byte[12]; final byte[] salt = new byte[16]; secureRandom.nextBytes(IV); @@ -281,8 +294,9 @@ public class ExportBackupService extends Service { final Progress progress = new Progress(mBuilder, max, count); final File file = new File(FileBackend.getBackupDirectory(this) + account.getJid().asBareJid().toEscapedString() + ".ceb"); files.add(file); - if (file.getParentFile().mkdirs()) { - Log.d(Config.LOGTAG, "created backup directory " + file.getParentFile().getAbsolutePath()); + final File directory = file.getParentFile(); + if (directory != null && directory.mkdirs()) { + Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath()); } final FileOutputStream fileOutputStream = new FileOutputStream(file); final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); @@ -290,8 +304,7 @@ public class ExportBackupService extends Service { dataOutputStream.flush(); final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER); - byte[] key = getKey(account.getPassword(), salt); - Log.d(Config.LOGTAG, backupFileHeader.toString()); + final byte[] key = getKey(password, salt); SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE); IvParameterSpec ivSpec = new IvParameterSpec(IV); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); @@ -315,7 +328,7 @@ public class ExportBackupService extends Service { return files; } - private void notifySuccess(List files) { + private void notifySuccess(final List files) { final String path = FileBackend.getBackupDirectory(this); PendingIntent openFolderIntent = null; @@ -331,14 +344,14 @@ public class ExportBackupService extends Service { if (files.size() > 0) { final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); ArrayList uris = new ArrayList<>(); - for(File file : files) { + for (File file : files) { uris.add(FileBackend.getUriForFile(this, file)); } intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType(MIME_TYPE); final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files)); - shareFilesIntent = PendingIntent.getActivity(this,190, chooser, PendingIntent.FLAG_UPDATE_CURRENT); + shareFilesIntent = PendingIntent.getActivity(this, 190, chooser, PendingIntent.FLAG_UPDATE_CURRENT); } NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); @@ -361,7 +374,7 @@ public class ExportBackupService extends Service { return null; } - private class Progress { + private static class Progress { private final NotificationCompat.Builder builder; private final int max; private final int count; diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 8ea3ed2ea..6f1ff65cc 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -4468,8 +4468,8 @@ public class XmppConnectionService extends Service { } private void injectServiceDiscoveryResult(Roster roster, String hash, String ver, ServiceDiscoveryResult disco) { - for (Contact contact : roster.getContacts()) { - for (Presence presence : contact.getPresences().getPresences().values()) { + for (final Contact contact : roster.getContacts()) { + for (final Presence presence : contact.getPresences().getPresences()) { if (hash.equals(presence.getHash()) && ver.equals(presence.getVer())) { presence.setServiceDiscoveryResult(disco); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 6308d74eb..6ff2135aa 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -116,6 +116,7 @@ import eu.siacs.conversations.utils.QuickLoader; import eu.siacs.conversations.utils.StylingHelper; import eu.siacs.conversations.utils.TimeFrameUtils; import eu.siacs.conversations.utils.UIHelper; +import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.chatstate.ChatState; import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; @@ -1342,11 +1343,28 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke Toast.makeText(getActivity(), R.string.only_one_call_at_a_time, Toast.LENGTH_LONG).show(); return; } + final Contact contact = conversation.getContact(); + if (contact.getPresences().anySupport(Namespace.JINGLE_MESSAGE)) { + triggerRtpSession(contact.getAccount(),contact.getJid().asBareJid(),action); + } else { + final RtpCapability.Capability capability; + if (action.equals(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL)) { + capability = RtpCapability.Capability.VIDEO; + } else { + capability = RtpCapability.Capability.AUDIO; + } + PresenceSelector.selectFullJidForDirectRtpConnection(activity, contact, capability, fullJid -> { + triggerRtpSession(contact.getAccount(), fullJid, action); + }); + } + } + + private void triggerRtpSession(final Account account, final Jid with, final String action) { final Intent intent = new Intent(activity, RtpSessionActivity.class); intent.setAction(action); - intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, contact.getAccount().getJid().toEscapedString()); - intent.putExtra(RtpSessionActivity.EXTRA_WITH, contact.getJid().asBareJid().toEscapedString()); + intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.getJid().toEscapedString()); + intent.putExtra(RtpSessionActivity.EXTRA_WITH, with.toEscapedString()); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); startActivity(intent); @@ -2120,6 +2138,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke final String downloadUuid = extras.getString(ConversationsActivity.EXTRA_DOWNLOAD_UUID); final String text = extras.getString(Intent.EXTRA_TEXT); final String nick = extras.getString(ConversationsActivity.EXTRA_NICK); + final String postInitAction = extras.getString(ConversationsActivity.EXTRA_POST_INIT_ACTION); final boolean asQuote = extras.getBoolean(ConversationsActivity.EXTRA_AS_QUOTE); final boolean pm = extras.getBoolean(ConversationsActivity.EXTRA_IS_PRIVATE_MESSAGE, false); final boolean doNotAppend = extras.getBoolean(ConversationsActivity.EXTRA_DO_NOT_APPEND, false); @@ -2160,6 +2179,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke appendText(text, doNotAppend); } } + if (ConversationsActivity.POST_ACTION_RECORD_VOICE.equals(postInitAction)) { + attachFile(ATTACHMENT_CHOICE_RECORD_VOICE); + return; + } final Message message = downloadUuid == null ? null : conversation.findMessageWithFileAndUuid(downloadUuid); if (message != null) { startDownloadable(message); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 43addb299..24e167e85 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -94,6 +94,8 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio public static final String EXTRA_NICK = "nick"; public static final String EXTRA_IS_PRIVATE_MESSAGE = "pm"; public static final String EXTRA_DO_NOT_APPEND = "do_not_append"; + public static final String EXTRA_POST_INIT_ACTION = "post_init_action"; + public static final String POST_ACTION_RECORD_VOICE = "record_voice"; private static List VIEW_AND_SHARE_ACTIONS = Arrays.asList( ACTION_VIEW_CONVERSATION, diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java index a6ac2ba1b..b7e018abf 100644 --- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java @@ -44,12 +44,14 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.databinding.ActivityRtpSessionBinding; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.services.AppRTCAudioManager; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.util.MainThreadExecutor; import eu.siacs.conversations.utils.PermissionUtils; import eu.siacs.conversations.utils.TimeFrameUtils; +import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection; import eu.siacs.conversations.xmpp.jingle.Media; @@ -101,6 +103,14 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe } } + private static void addSink(final VideoTrack videoTrack, final SurfaceViewRenderer surfaceViewRenderer) { + try { + videoTrack.addSink(surfaceViewRenderer); + } catch (final IllegalStateException e) { + Log.e(Config.LOGTAG, "possible race condition on trying to display video track. ignoring", e); + } + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -297,7 +307,13 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe private void proposeJingleRtpSession(final Account account, final Jid with, final Set media) { checkMicrophoneAvailability(); - xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession(account, with, media); + if (with.isBareJid()) { + xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession(account, with, media); + } else { + final String sessionId = xmppConnectionService.getJingleConnectionManager().initializeRtpSession(account, with, media); + initializeActivityWithRunningRtpSession(account, with, sessionId); + resetIntent(account, with, sessionId); + } putScreenInCallMode(media); } @@ -372,7 +388,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe } } - @RequiresApi(api = Build.VERSION_CODES.O) private void startPictureInPicture() { try { @@ -412,12 +427,12 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe final WeakReference reference = xmppConnectionService.getJingleConnectionManager() .findJingleRtpConnection(account, with, sessionId); if (reference == null || reference.get() == null) { - finish(); - return true; + throw new IllegalStateException("failed to initialize activity with running rtp session. session not found"); } this.rtpConnectionReference = reference; final RtpEndUserState currentState = requireRtpConnection().getEndUserState(); if (currentState == RtpEndUserState.ENDED) { + reference.get().throwStateTransitionException(); finish(); return true; } @@ -436,8 +451,12 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe return false; } - private void reInitializeActivityWithRunningRapSession(final Account account, Jid with, String sessionId) { + private void reInitializeActivityWithRunningRtpSession(final Account account, Jid with, String sessionId) { runOnUiThread(() -> initializeActivityWithRunningRtpSession(account, with, sessionId)); + resetIntent(account, with, sessionId); + } + + private void resetIntent(final Account account, final Jid with, final String sessionId) { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString()); intent.putExtra(EXTRA_WITH, with.toEscapedString()); @@ -551,11 +570,13 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe this.binding.acceptCall.setImageResource(R.drawable.ic_call_white_48dp); this.binding.acceptCall.setVisibility(View.VISIBLE); } else if (state == RtpEndUserState.DECLINED_OR_BUSY) { - this.binding.rejectCall.setVisibility(View.INVISIBLE); - this.binding.endCall.setOnClickListener(this::exit); - this.binding.endCall.setImageResource(R.drawable.ic_clear_white_48dp); - this.binding.endCall.setVisibility(View.VISIBLE); - this.binding.acceptCall.setVisibility(View.INVISIBLE); + this.binding.rejectCall.setOnClickListener(this::exit); + this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp); + this.binding.rejectCall.setVisibility(View.VISIBLE); + this.binding.endCall.setVisibility(View.INVISIBLE); + this.binding.acceptCall.setOnClickListener(this::recordVoiceMail); + this.binding.acceptCall.setImageResource(R.drawable.ic_voicemail_white_24dp); + this.binding.acceptCall.setVisibility(View.VISIBLE); } else if (asList(RtpEndUserState.CONNECTIVITY_ERROR, RtpEndUserState.APPLICATION_ERROR, RtpEndUserState.RETRACTED).contains(state)) { this.binding.rejectCall.setOnClickListener(this::exit); this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp); @@ -762,14 +783,14 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe //paint local view over remote view binding.localVideo.setZOrderMediaOverlay(true); binding.localVideo.setMirror(requireRtpConnection().isFrontCamera()); - localVideoTrack.get().addSink(binding.localVideo); + addSink(localVideoTrack.get(), binding.localVideo); } else { binding.localVideo.setVisibility(View.GONE); } final Optional remoteVideoTrack = getRemoteVideoTrack(); if (remoteVideoTrack.isPresent()) { ensureSurfaceViewRendererIsSetup(binding.remoteVideo); - remoteVideoTrack.get().addSink(binding.remoteVideo); + addSink(remoteVideoTrack.get(), binding.remoteVideo); if (state == RtpEndUserState.CONNECTED) { binding.appBarLayout.setVisibility(View.GONE); getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); @@ -828,7 +849,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe } private void retry(View view) { - Log.d(Config.LOGTAG, "attempting retry"); final Intent intent = getIntent(); final Account account = extractAccount(intent); final Jid with = Jid.ofEscaped(intent.getStringExtra(EXTRA_WITH)); @@ -836,10 +856,25 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe final String action = intent.getAction(); final Set media = actionToMedia(lastAction == null ? action : lastAction); this.rtpConnectionReference = null; + Log.d(Config.LOGTAG, "attempting retry with " + with.toEscapedString()); proposeJingleRtpSession(account, with, media); } - private void exit(View view) { + private void exit(final View view) { + finish(); + } + + private void recordVoiceMail(final View view) { + final Intent intent = getIntent(); + final Account account = extractAccount(intent); + final Jid with = Jid.ofEscaped(intent.getStringExtra(EXTRA_WITH)); + final Conversation conversation = xmppConnectionService.findOrCreateConversation(account, with, false, true); + final Intent launchIntent = new Intent(this, ConversationsActivity.class); + launchIntent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION); + launchIntent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, conversation.getUuid()); + launchIntent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtra(ConversationsActivity.EXTRA_POST_INIT_ACTION, ConversationsActivity.POST_ACTION_RECORD_VOICE); + startActivity(launchIntent); finish(); } @@ -875,7 +910,7 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe return; } //this happens when going from proposed session to actual session - reInitializeActivityWithRunningRapSession(account, with, sessionId); + reInitializeActivityWithRunningRtpSession(account, with, sessionId); return; } final AbstractJingleConnection.Id id = requireRtpConnection().getId(); @@ -952,8 +987,12 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe private void resetIntent(final Account account, Jid with, final RtpEndUserState state, final Set media) { final Intent intent = new Intent(Intent.ACTION_VIEW); - intent.putExtra(EXTRA_WITH, with.asBareJid().toEscapedString()); intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString()); + if (account.getRoster().getContact(with).getPresences().anySupport(Namespace.JINGLE_MESSAGE)) { + intent.putExtra(EXTRA_WITH, with.asBareJid().toEscapedString()); + } else { + intent.putExtra(EXTRA_WITH, with.toEscapedString()); + } intent.putExtra(EXTRA_LAST_REPORTED_STATE, state.toString()); intent.putExtra(EXTRA_LAST_ACTION, media.contains(Media.VIDEO) ? ACTION_MAKE_VIDEO_CALL : ACTION_MAKE_VOICE_CALL); setIntent(intent); diff --git a/src/main/java/eu/siacs/conversations/ui/util/PresenceSelector.java b/src/main/java/eu/siacs/conversations/ui/util/PresenceSelector.java index defabcb4a..14e82b406 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/PresenceSelector.java +++ b/src/main/java/eu/siacs/conversations/ui/util/PresenceSelector.java @@ -44,92 +44,110 @@ import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Presences; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.jingle.RtpCapability; public class PresenceSelector { - public static void showPresenceSelectionDialog(Activity activity, final Conversation conversation, final OnPresenceSelected listener) { - final Contact contact = conversation.getContact(); - final Presences presences = contact.getPresences(); - AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setTitle(activity.getString(R.string.choose_presence)); - final String[] resourceArray = presences.toResourceArray(); - Pair, Map> typeAndName = presences.toTypeAndNameMap(); - final Map resourceTypeMap = typeAndName.first; - final Map resourceNameMap = typeAndName.second; - final String[] readableIdentities = new String[resourceArray.length]; - final AtomicInteger selectedResource = new AtomicInteger(0); - for (int i = 0; i < resourceArray.length; ++i) { - String resource = resourceArray[i]; - if (resource.equals(contact.getLastResource())) { - selectedResource.set(i); - } - String type = resourceTypeMap.get(resource); - String name = resourceNameMap.get(resource); - if (type != null) { - if (Collections.frequency(resourceTypeMap.values(), type) == 1) { - readableIdentities[i] = translateType(activity, type); - } else if (name != null) { - if (Collections.frequency(resourceNameMap.values(), name) == 1 - || CryptoHelper.UUID_PATTERN.matcher(resource).matches()) { - readableIdentities[i] = translateType(activity, type) + " (" + name + ")"; - } else { - readableIdentities[i] = translateType(activity, type) + " (" + name + " / " + resource + ")"; - } - } else { - readableIdentities[i] = translateType(activity, type) + " (" + resource + ")"; - } - } else { - readableIdentities[i] = resource; - } - } - builder.setSingleChoiceItems(readableIdentities, - selectedResource.get(), - (dialog, which) -> selectedResource.set(which)); - builder.setNegativeButton(R.string.cancel, null); - builder.setPositiveButton(R.string.ok, (dialog, which) -> { - try { - Jid next = Jid.of(contact.getJid().getLocal(), contact.getJid().getDomain(), resourceArray[selectedResource.get()]); - conversation.setNextCounterpart(next); - } catch (IllegalArgumentException e) { - conversation.setNextCounterpart(null); - } - listener.onPresenceSelected(); - }); - builder.create().show(); - } + public static void showPresenceSelectionDialog(Activity activity, final Conversation conversation, final OnPresenceSelected listener) { + final Contact contact = conversation.getContact(); + final String[] resourceArray = contact.getPresences().toResourceArray(); + showPresenceSelectionDialog(activity, contact, resourceArray, fullJid -> { + conversation.setNextCounterpart(fullJid); + listener.onPresenceSelected(); + }); + } - public static void warnMutualPresenceSubscription(Activity activity, final Conversation conversation, final OnPresenceSelected listener) { - AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setTitle(conversation.getContact().getJid().toString()); - builder.setMessage(R.string.without_mutual_presence_updates); - builder.setNegativeButton(R.string.cancel, null); - builder.setPositiveButton(R.string.ignore, (dialog, which) -> { - conversation.setNextCounterpart(null); - if (listener != null) { - listener.onPresenceSelected(); - } - }); - builder.create().show(); - } + public static void selectFullJidForDirectRtpConnection(final Activity activity, final Contact contact, final RtpCapability.Capability required, final OnFullJidSelected onFullJidSelected) { + final String[] resources = RtpCapability.filterPresences(contact, required); + if (resources.length == 1) { + onFullJidSelected.onFullJidSelected(contact.getJid().withResource(resources[0])); + } else { + showPresenceSelectionDialog(activity, contact, resources, onFullJidSelected); + } + } - private static String translateType(Context context, String type) { - switch (type.toLowerCase()) { - case "pc": - return context.getString(R.string.type_pc); - case "phone": - return context.getString(R.string.type_phone); - case "tablet": - return context.getString(R.string.type_tablet); - case "web": - return context.getString(R.string.type_web); - case "console": - return context.getString(R.string.type_console); - default: - return type; - } - } + private static void showPresenceSelectionDialog(final Activity activity, final Contact contact, final String[] resourceArray, final OnFullJidSelected onFullJidSelected) { + final Presences presences = contact.getPresences(); + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(activity.getString(R.string.choose_presence)); + Pair, Map> typeAndName = presences.toTypeAndNameMap(); + final Map resourceTypeMap = typeAndName.first; + final Map resourceNameMap = typeAndName.second; + final String[] readableIdentities = new String[resourceArray.length]; + final AtomicInteger selectedResource = new AtomicInteger(0); + for (int i = 0; i < resourceArray.length; ++i) { + String resource = resourceArray[i]; + if (resource.equals(contact.getLastResource())) { + selectedResource.set(i); + } + String type = resourceTypeMap.get(resource); + String name = resourceNameMap.get(resource); + if (type != null) { + if (Collections.frequency(resourceTypeMap.values(), type) == 1) { + readableIdentities[i] = translateType(activity, type); + } else if (name != null) { + if (Collections.frequency(resourceNameMap.values(), name) == 1 + || CryptoHelper.UUID_PATTERN.matcher(resource).matches()) { + readableIdentities[i] = translateType(activity, type) + " (" + name + ")"; + } else { + readableIdentities[i] = translateType(activity, type) + " (" + name + " / " + resource + ")"; + } + } else { + readableIdentities[i] = translateType(activity, type) + " (" + resource + ")"; + } + } else { + readableIdentities[i] = resource; + } + } + builder.setSingleChoiceItems(readableIdentities, + selectedResource.get(), + (dialog, which) -> selectedResource.set(which)); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton( + R.string.ok, + (dialog, which) -> onFullJidSelected.onFullJidSelected( + Jid.of(contact.getJid().getLocal(), contact.getJid().getDomain(), resourceArray[selectedResource.get()]) + ) + ); + builder.create().show(); + } - public interface OnPresenceSelected { - void onPresenceSelected(); - } + public static void warnMutualPresenceSubscription(Activity activity, final Conversation conversation, final OnPresenceSelected listener) { + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(conversation.getContact().getJid().toString()); + builder.setMessage(R.string.without_mutual_presence_updates); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ignore, (dialog, which) -> { + conversation.setNextCounterpart(null); + if (listener != null) { + listener.onPresenceSelected(); + } + }); + builder.create().show(); + } + + private static String translateType(Context context, String type) { + switch (type.toLowerCase()) { + case "pc": + return context.getString(R.string.type_pc); + case "phone": + return context.getString(R.string.type_phone); + case "tablet": + return context.getString(R.string.type_tablet); + case "web": + return context.getString(R.string.type_web); + case "console": + return context.getString(R.string.type_console); + default: + return type; + } + } + + public interface OnPresenceSelected { + void onPresenceSelected(); + } + + public interface OnFullJidSelected { + void onFullJidSelected(Jid jid); + } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java index ef28a2eb3..679a30abe 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java @@ -1,5 +1,6 @@ package eu.siacs.conversations.xmpp.jingle; +import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import com.google.common.base.Preconditions; @@ -61,6 +62,10 @@ public abstract class AbstractJingleConnection { return new Id(account, with, sessionId); } + public static Id of(Account account, Jid with) { + return new Id(account, with, JingleConnectionManager.nextRandomId()); + } + public static Id of(Message message) { return new Id( message.getConversation().getAccount(), @@ -78,14 +83,14 @@ public abstract class AbstractJingleConnection { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Id id = (Id) o; - return Objects.equal(account.getJid(), id.account.getJid()) && + return Objects.equal(account.getUuid(), id.account.getUuid()) && Objects.equal(with, id.with) && Objects.equal(sessionId, id.sessionId); } @Override public int hashCode() { - return Objects.hashCode(account.getJid(), with, sessionId); + return Objects.hashCode(account.getUuid(), with, sessionId); } @Override @@ -102,6 +107,15 @@ public abstract class AbstractJingleConnection { public String getSessionId() { return sessionId; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("account", account.getJid()) + .add("with", with) + .add("sessionId", sessionId) + .toString(); + } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java index d8be779ef..4a1f7148f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -11,6 +11,7 @@ import com.google.common.cache.CacheBuilder; import com.google.common.collect.Collections2; import com.google.common.collect.ComparisonChain; import com.google.common.collect.ImmutableSet; +import com.google.j2objc.annotations.Weak; import java.lang.ref.WeakReference; import java.security.SecureRandom; @@ -134,7 +135,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { } } - public Optional findMatchingSessionProposal(final Account account, final Jid with, final Set media) { + private Optional findMatchingSessionProposal(final Account account, final Jid with, final Set media) { synchronized (this.rtpSessionProposals) { for (Map.Entry entry : this.rtpSessionProposals.entrySet()) { final RtpSessionProposal proposal = entry.getKey(); @@ -446,7 +447,10 @@ public class JingleConnectionManager extends AbstractConnectionManager { } void finishConnection(final AbstractJingleConnection connection) { - this.connections.remove(connection.getId()); + final AbstractJingleConnection.Id id = connection.getId(); + if (this.connections.remove(id) == null) { + throw new IllegalStateException(String.format("Unable to finish connection with id=%s", id.toString())); + } } void getPrimaryCandidate(final Account account, final boolean initiator, final OnPrimaryCandidateFound listener) { @@ -520,6 +524,15 @@ public class JingleConnectionManager extends AbstractConnectionManager { mXmppConnectionService.sendMessagePacket(account, messagePacket); } + public String initializeRtpSession(final Account account, final Jid with, final Set media) { + final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with); + final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, account.getJid()); + rtpConnection.setProposedMedia(media); + this.connections.put(id, rtpConnection); + rtpConnection.sendSessionInitiate(); + return id.sessionId; + } + public void proposeJingleRtpSession(final Account account, final Jid with, final Set media) { synchronized (this.rtpSessionProposals) { for (Map.Entry entry : this.rtpSessionProposals.entrySet()) { @@ -667,7 +680,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { throw e; } - public void endSession(AbstractJingleConnection.Id id, final AbstractJingleConnection.State state) { + void endSession(AbstractJingleConnection.Id id, final AbstractJingleConnection.State state) { this.endedSessions.put(PersistableSessionId.of(id), state); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java index 5d25cb3b0..2f84f7c45 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java @@ -417,7 +417,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple final Jid jid = this.id.with; String resource = jid != null ? jid.getResource() : null; if (resource != null) { - Presence presence = this.id.account.getRoster().getContact(jid).getPresences().getPresences().get(resource); + Presence presence = this.id.account.getRoster().getContact(jid).getPresences().get(resource); ServiceDiscoveryResult result = presence != null ? presence.getServiceDiscoveryResult() : null; return result == null ? Collections.emptyList() : result.getFeatures(); } else { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index 0af69ca28..8ff496835 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -123,6 +123,7 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web private final ArrayDeque pendingIceCandidates = new ArrayDeque<>(); private final Message message; private State state = State.NULL; + private StateTransitionException stateTransitionException; private Set proposedMedia; private RtpContentMap initiatorRtpContentMap; private RtpContentMap responderRtpContentMap; @@ -639,6 +640,10 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web } } + public void sendSessionInitiate() { + sendSessionInitiate(this.proposedMedia, State.SESSION_INITIALIZED); + } + private void sendSessionInitiate(final Set media, final State targetState) { Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate"); discoverIceServers(iceServers -> sendSessionInitiate(media, targetState, iceServers)); @@ -771,8 +776,16 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null); } + public void throwStateTransitionException() { + final StateTransitionException exception = this.stateTransitionException; + if (exception != null) { + throw new IllegalStateException(String.format("Transition to %s did not call finish", exception.state), exception); + } + } + public RtpEndUserState getEndUserState() { switch (this.state) { + case NULL: case PROPOSED: case SESSION_INITIALIZED: if (isInitiator()) { @@ -828,10 +841,19 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web public Set getMedia() { final State current = getState(); if (current == State.NULL) { + if (isInitiator()) { + return Preconditions.checkNotNull( + this.proposedMedia, + "RTP connection has not been initialized properly" + ); + } throw new IllegalStateException("RTP connection has not been initialized yet"); } if (Arrays.asList(State.PROPOSED, State.PROCEED).contains(current)) { - return Preconditions.checkNotNull(this.proposedMedia, "RTP connection has not been initialized properly"); + return Preconditions.checkNotNull( + this.proposedMedia, + "RTP connection has not been initialized properly" + ); } final RtpContentMap initiatorContentMap = initiatorRtpContentMap; if (initiatorContentMap != null) { @@ -983,6 +1005,7 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web final Collection validTransitions = VALID_TRANSITIONS.get(this.state); if (validTransitions != null && validTransitions.contains(target)) { this.state = target; + this.stateTransitionException = new StateTransitionException(target); if (runnable != null) { runnable.run(); } @@ -1231,4 +1254,12 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web private interface OnIceServersDiscovered { void onIceServersDiscovered(List iceServers); } + + private static class StateTransitionException extends Exception { + private final State state; + + private StateTransitionException(final State state) { + this.state = state; + } + } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java index 646137495..7fad0459a 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpCapability.java @@ -1,8 +1,10 @@ package eu.siacs.conversations.xmpp.jingle; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Presence; @@ -37,10 +39,25 @@ public class RtpCapability { return Capability.NONE; } + public static String[] filterPresences(final Contact contact, Capability required) { + final Presences presences = contact.getPresences(); + final ArrayList resources = new ArrayList<>(); + for(final Map.Entry presence : presences.getPresencesMap().entrySet()) { + final Capability capability = check(presence.getValue()); + if (capability == Capability.NONE) { + continue; + } + if (required == Capability.AUDIO || capability == required) { + resources.add(presence.getKey()); + } + } + return resources.toArray(new String[0]); + } + public static Capability check(final Contact contact) { final Presences presences = contact.getPresences(); Capability result = Capability.NONE; - for(Presence presence : presences.getPresences().values()) { + for(Presence presence : presences.getPresences()) { Capability capability = check(presence); if (capability == Capability.VIDEO) { result = capability; diff --git a/src/main/res/drawable-hdpi/ic_voicemail_white_24dp.png b/src/main/res/drawable-hdpi/ic_voicemail_white_24dp.png new file mode 100644 index 000000000..03a62e15f Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_voicemail_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_voicemail_white_24dp.png b/src/main/res/drawable-mdpi/ic_voicemail_white_24dp.png new file mode 100644 index 000000000..e5aa7db05 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_voicemail_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_voicemail_white_24dp.png b/src/main/res/drawable-xhdpi/ic_voicemail_white_24dp.png new file mode 100644 index 000000000..59126d706 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_voicemail_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_voicemail_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_voicemail_white_24dp.png new file mode 100644 index 000000000..28b8e936a Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_voicemail_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_voicemail_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_voicemail_white_24dp.png new file mode 100644 index 000000000..820ff5066 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_voicemail_white_24dp.png differ diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml index 8a5dfb6b6..58f1ac344 100644 --- a/src/main/res/values-fr/strings.xml +++ b/src/main/res/values-fr/strings.xml @@ -246,7 +246,7 @@ Votre correspondant vous a ajouté dans sa liste de contacts Ajouter en retour %s a tout lu jusqu\'ici - %s a tout lu jusqu\'ici + %s ont tout lu jusqu\'ici %1$s+%2$d autres ont tout lu jusqu\'ici Tout le monde a lu jusqu\'ici Publier @@ -398,7 +398,7 @@ Jusqu\'à nouvel ordre Répéter les notifications Répondre - Marqué comme lu + Marquer comme lu Saisie Touche Entrée pour envoyer Utiliser la touche Entrée pour envoyer un message. @@ -918,6 +918,8 @@ Vous ne pouvez prendre qu\'un appel à la fois. Reprendre l\'appel en cours Impossible de changer de caméra + Ajouter aux favoris + Enlever des favoris Voir %1$d participant Voir %1$d participants diff --git a/src/main/res/values-hu/strings.xml b/src/main/res/values-hu/strings.xml index 82327c0d5..dd1f62576 100644 --- a/src/main/res/values-hu/strings.xml +++ b/src/main/res/values-hu/strings.xml @@ -272,6 +272,8 @@ Az értesítések el lesznek némítva a csendes órák alatt Egyéb Szinkronizálás a könyvjelzőkkel + Automatikusan csatlakozzon a csoportos csevegésekhez, ha ez szerepel a könyvjelzőben + OMEMO ujjlenyomat a vágólapra lett másolva Ki van tiltva ebből a csoportos csevegésből Ez a csoportos csevegés csak tagoknak szól Erőforráskényszer @@ -886,6 +888,8 @@ Egyszerre csak egy hívásban vehet részt. Visszatérés a kimenő híváshoz Nem sikerült átváltani a kamerát + Hozzáadás a kedvencekhez + Eltávolítás a kedvencekből %1$d résztvevő megtekintése %1$d résztvevő megtekintése diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml index effaf0ef9..111a65c43 100644 --- a/src/main/res/values-ru/strings.xml +++ b/src/main/res/values-ru/strings.xml @@ -207,25 +207,32 @@ Добавить в ответ %s прочит. сообщ. до этого момента %s прочитали сообщения до этого момента + %1$s + ещё %2$d прочитали до этого места Все прочитали сообщения до этого момента Опубликовать + Нажмите на аватар, чтобы выбрать новую фотографию из галереи Установка… Сервер отклонил размещение аватара + Не удалось преобразовать вашу фотографию Не удалось сохранить аватар (Или долгое прикосновение, чтобы вернуть значения по умолчанию) + Ваш сервер не поддерживает публикацию аватаров шёпот отправить %s Приватное сообщение %s Подключиться Аккаунт уже существует Далее + Сеанс установлен Пропустить Отключить уведомления Включить Конференция требует авторизации Введите пароль + Пожалуйста, сначала запросите обновления присутствия у вашего собеседника.\n\nЭта информация будет использоваться для определения того, каким клиентом пользуется ваш собеседник. Запросить сейчас Игнорировать + Внимание: Если обновления присутствия не включены на обеих сторонах, это может привести к возникновению неожиданных проблем.\n\nПросмотрите сведения о контакте для проверки настроек обновлений присутствия. Безопасность Разрешить исправление сообщений Позволить контактам редактировать сообщения @@ -248,6 +255,7 @@ Конференция была остановлена Вы больше не состоите в этой конференции используется аккаунт %s + размещено на %s Проверка %s на сервере HTTP Вы неподключены. Попробуйте позже Проверить размер %s @@ -778,6 +786,7 @@ Пожалуйста, введите ваше имя, чтобы другие люди, у которых нет вас в списке контактов, знали кто вы. Ваше имя Введите ваше имя + Используйте кнопку редактирования, чтобы задать ваше имя. Отклонить запрос Установите Orbot Запустите Orbot diff --git a/src/main/res/values-tr-rTR/strings.xml b/src/main/res/values-tr-rTR/strings.xml index 747fa315b..06db19906 100644 --- a/src/main/res/values-tr-rTR/strings.xml +++ b/src/main/res/values-tr-rTR/strings.xml @@ -3,7 +3,11 @@ Ayarlar Yeni konuşma Hesapları yönet + Hesabı yönet + Bu konuşmayı kapat Kişi bilgileri + Küme konuşması ayrıntıları + Kanal ayrıntıları Güvenli konuşma Hesap ekle İsmi düzenle @@ -13,14 +17,20 @@ Kişiyi engellemekten vazgeç Alan adını engelle Alan adını engellemekten vazgeç + Katılımcıyı engelle + Katılımcının engelini kaldır Hesapları yönet Ayarlar Konuşmayla paylaş Konuşma Başlat + Kişi Seç + Kişi Seç + Hesap aracılığıyla paylaş Listeyi blokla şimdi 1 dakika önce %d dakika önc + %d okunmamış konuşma gönderiyor… İleti deşifre ediliyor. Lütfen bekleyin… OpenPGP şifreli ileti @@ -35,10 +45,14 @@ %s üzerinden gelen tüm kişileri engellemek istiyor musunuz? %s üzerinden gelen kişilerdeki engellemeyi kaldırmak istiyor musunuz? Kişi engellendi + Engellendi Sunucuda yeni bir hesap oluştur Sunucudaki şifreni değiştir Paylaş... + Konuşma başlat + Kişi davet et Kişiler + Kişi İptal et Ayarla Ekle diff --git a/src/main/res/values-uk/strings.xml b/src/main/res/values-uk/strings.xml index 7eb79819b..2de77dbfe 100644 --- a/src/main/res/values-uk/strings.xml +++ b/src/main/res/values-uk/strings.xml @@ -924,9 +924,9 @@ Активний відеовиклик Вимкнути ToR для здійснення викликів Вхідний виклик - %s · Вхідний виклик · %s + Вхідний виклик · %s Вихідний виклик - %s · Вихідний виклик · %s + Вихідний виклик · %s Пропущені виклики Голосовий виклик Відеовиклик diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml index bb08debaf..07324fe66 100644 --- a/src/main/res/values-zh-rCN/strings.xml +++ b/src/main/res/values-zh-rCN/strings.xml @@ -9,7 +9,7 @@ 群聊详情 频道详情 加密聊天 - 添加账号 + 添加账户 编辑名称 添加到通讯录 从畅聊通讯录中删除 @@ -167,7 +167,7 @@ OTR OpenPGP OMEMO - 删除账号 + 删除账户 暂时不可用 发布头像 发布OpenPGP公钥 @@ -261,7 +261,7 @@ 至%s 与%s私聊 连接 - 该账号已存在 + 该账户已存在 下一步 会话已建立 跳过