diff --git a/CHANGELOG.md b/CHANGELOG.md index 9717ff10..d86390fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Built 234 (2.0.1) +2024-03-20 + +- Several improvements to the video calls UI +- Fix a api key registration bug on first Keycloak binding +- Minor improvements to OpenStreetMaps integration +- Redesign of the message upload coordinator for improved performances when sending many messages + # Build 233 (2.0) 2024-03-14 diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/Constants.java b/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/Constants.java index cce70768..a0a989e3 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/Constants.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/Constants.java @@ -88,6 +88,7 @@ public abstract class Constants { public static final int DEFAULT_ATTACHMENT_CHUNK_LENGTH = 4*2048*1024; public static final int MAX_MESSAGE_EXTENDED_CONTENT_LENGTH = 50 * 1024; + public static final int MAX_UPLOAD_MESSAGE_BATCH_SIZE = 50; public static final UID BROADCAST_UID = new UID(new byte[]{(byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff, (byte)0xff}); @@ -110,6 +111,7 @@ public abstract class Constants { // prefixes for various types of signature public static final int SIGNATURE_PADDING_LENGTH = 16; + public enum SignatureContext { SERVER_AUTHENTICATION, MUTUAL_SCAN, diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/containers/StringAndBoolean.java b/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/containers/StringAndBoolean.java new file mode 100644 index 00000000..e95dcfe4 --- /dev/null +++ b/obv_engine/engine/src/main/java/io/olvid/engine/datatypes/containers/StringAndBoolean.java @@ -0,0 +1,59 @@ +/* + * Olvid for Android + * Copyright © 2019-2024 Olvid SAS + * + * This file is part of Olvid for Android. + * + * Olvid is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * Olvid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Olvid. If not, see . + */ + +package io.olvid.engine.datatypes.containers; + + +import java.nio.charset.StandardCharsets; + +import io.olvid.engine.crypto.Hash; +import io.olvid.engine.crypto.Suite; +import io.olvid.engine.datatypes.UID; + +public class StringAndBoolean { + public final String string; + public final boolean bool; + + public StringAndBoolean(String string, boolean bool) { + this.string = string; + this.bool = bool; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof StringAndBoolean)) { + return false; + } + StringAndBoolean other = (StringAndBoolean) o; + return string.equals(other.string) && bool == other.bool; + } + + @Override + public int hashCode() { + return string.hashCode() * 31 + Boolean.hashCode(bool); + } + + public static UID computeUniqueUid(String string, boolean bool) { + Hash sha256 = Suite.getHash(Hash.SHA256); + byte[] input = new byte[string.getBytes(StandardCharsets.UTF_8).length + 1]; + System.arraycopy(string.getBytes(StandardCharsets.UTF_8), 0, input, 0, input.length - 1); + input[input.length - 1] = bool ? (byte) 0x01 : (byte) 0x00; + return new UID(sha256.digest(input)); + } +} diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/engine/Engine.java b/obv_engine/engine/src/main/java/io/olvid/engine/engine/Engine.java index fc6a4732..7351fb85 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/engine/Engine.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/engine/Engine.java @@ -672,7 +672,7 @@ public RegisterApiKeyResult registerOwnedIdentityApiKeyOnServer(byte[] bytesOwne byte[] serverSessionToken = fetchManager.getServerAuthenticationToken(ownedIdentity); if (serverSessionToken == null) { fetchManager.createServerSession(ownedIdentity); - return RegisterApiKeyResult.FAILED; + return RegisterApiKeyResult.WAIT_FOR_SERVER_SESSION; } StandaloneServerQueryOperation standaloneServerQueryOperation = new StandaloneServerQueryOperation(new ServerQuery(null, ownedIdentity, new ServerQuery.RegisterApiKeyQuery(ownedIdentity, serverSessionToken, Logger.getUuidString(apiKey)))); @@ -693,7 +693,7 @@ public RegisterApiKeyResult registerOwnedIdentityApiKeyOnServer(byte[] bytesOwne } case StandaloneServerQueryOperation.RFC_INVALID_SERVER_SESSION: { recreateServerSession(bytesOwnedIdentity); - break; + return RegisterApiKeyResult.WAIT_FOR_SERVER_SESSION; } case StandaloneServerQueryOperation.RFC_UNSUPPORTED_SERVER_QUERY_TYPE: case StandaloneServerQueryOperation.RFC_NETWORK_ERROR: diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/engine/types/RegisterApiKeyResult.java b/obv_engine/engine/src/main/java/io/olvid/engine/engine/types/RegisterApiKeyResult.java index d9a7bfa6..fb0e67e9 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/engine/types/RegisterApiKeyResult.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/engine/types/RegisterApiKeyResult.java @@ -23,4 +23,5 @@ public enum RegisterApiKeyResult { SUCCESS, INVALID_KEY, FAILED, + WAIT_FOR_SERVER_SESSION, } diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/identity/IdentityManager.java b/obv_engine/engine/src/main/java/io/olvid/engine/identity/IdentityManager.java index 83a626cf..29a5f911 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/identity/IdentityManager.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/identity/IdentityManager.java @@ -197,6 +197,7 @@ public void initialisationComplete() { protocolStarterDelegate.startDeviceDiscoveryProtocolWithinTransaction(identityManagerSession.session, contactIdentity.getOwnedIdentity(), contactIdentity.getContactIdentity()); } } + identityManagerSession.session.commit(); } } catch (Exception e) { e.printStackTrace(); diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/identity/databases/ContactDevice.java b/obv_engine/engine/src/main/java/io/olvid/engine/identity/databases/ContactDevice.java index 7e6e5946..ad9bab6b 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/identity/databases/ContactDevice.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/identity/databases/ContactDevice.java @@ -52,6 +52,7 @@ public class ContactDevice implements ObvDatabase { static final String OWNED_IDENTITY = "owned_identity"; private byte[] serializedDeviceCapabilities; static final String SERIALIZED_DEVICE_CAPABILITIES = "serialized_device_capabilities"; + // TODO: add a last ping sent timestamp to limit useless contact discoveries public UID getUid() { return uid; diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networkfetch/coordinators/DownloadMessagesAndListAttachmentsCoordinator.java b/obv_engine/engine/src/main/java/io/olvid/engine/networkfetch/coordinators/DownloadMessagesAndListAttachmentsCoordinator.java index f42f935a..c849d57b 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/networkfetch/coordinators/DownloadMessagesAndListAttachmentsCoordinator.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networkfetch/coordinators/DownloadMessagesAndListAttachmentsCoordinator.java @@ -126,12 +126,14 @@ public void initialQueueing() { PendingDeleteFromServer.create(fetchManagerSession, inboxMessage.getOwnedIdentity(), inboxMessage.getUid()); } } + fetchManagerSession.session.commit(); // check all decrypted messages, with attachments, that are not yet marked as listed on the server InboxMessage[] messagesToMarkAsListedOnServer = InboxMessage.getMessagesThatCanBeMarkedAsListedOnServer(fetchManagerSession); for (InboxMessage inboxMessage : messagesToMarkAsListedOnServer) { fetchManagerSession.markAsListedOnServerListener.messageCanBeMarkedAsListedOnServer(inboxMessage.getOwnedIdentity(), inboxMessage.getUid()); } + } catch (SQLException e) { e.printStackTrace(); } diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/SendManager.java b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/SendManager.java index f0dec35b..4ed61839 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/SendManager.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/SendManager.java @@ -146,7 +146,8 @@ public void post(Session session, MessageToSend messageToSend) { messageToSend.getEncryptedContent(), messageToSend.getEncryptedExtendedContent(), messageToSend.isApplicationMessage(), - messageToSend.isVoipMessage() + messageToSend.isVoipMessage(), + messageToSend.getAttachments() != null && messageToSend.getAttachments().length != 0 ); if (messageToSend.getHeaders() != null) { diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendAttachmentCoordinator.java b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendAttachmentCoordinator.java index b5858c04..1ea9ddd8 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendAttachmentCoordinator.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendAttachmentCoordinator.java @@ -36,6 +36,7 @@ import io.olvid.engine.datatypes.PriorityOperation; import io.olvid.engine.datatypes.PriorityOperationQueue; import io.olvid.engine.datatypes.UID; +import io.olvid.engine.datatypes.containers.IdentityAndUid; import io.olvid.engine.datatypes.containers.IdentityAndUidAndNumber; import io.olvid.engine.datatypes.notifications.IdentityNotifications; import io.olvid.engine.datatypes.notifications.UploadNotifications; @@ -287,4 +288,8 @@ public long getInitialPriority() { return initialPriority; } } + + public interface MessageBatchProvider { + IdentityAndUid[] getBatchOFMessageUids(); + } } diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendMessageCoordinator.java b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendMessageCoordinator.java index aaf163d8..fe3e7b5d 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendMessageCoordinator.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/coordinators/SendMessageCoordinator.java @@ -21,37 +21,52 @@ import java.sql.SQLException; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Queue; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import javax.net.ssl.SSLSocketFactory; import io.olvid.engine.Logger; +import io.olvid.engine.datatypes.Constants; import io.olvid.engine.datatypes.ExponentialBackoffRepeatingScheduler; import io.olvid.engine.datatypes.Identity; +import io.olvid.engine.datatypes.NoDuplicateOperationQueue; import io.olvid.engine.datatypes.Operation; import io.olvid.engine.datatypes.OperationQueue; import io.olvid.engine.datatypes.UID; import io.olvid.engine.datatypes.containers.IdentityAndUid; +import io.olvid.engine.datatypes.containers.StringAndBoolean; import io.olvid.engine.datatypes.notifications.IdentityNotifications; import io.olvid.engine.metamanager.NotificationListeningDelegate; import io.olvid.engine.networksend.databases.OutboxMessage; import io.olvid.engine.networksend.datatypes.SendManagerSession; import io.olvid.engine.networksend.datatypes.SendManagerSessionFactory; +import io.olvid.engine.networksend.operations.BatchUploadMessagesCompositeOperation; import io.olvid.engine.networksend.operations.UploadMessageCompositeOperation; -public class SendMessageCoordinator implements OutboxMessage.NewOutboxMessageListener, Operation.OnCancelCallback, Operation.OnFinishCallback { +public class SendMessageCoordinator implements OutboxMessage.NewOutboxMessageListener { + private final SendManagerSessionFactory sendManagerSessionFactory; private final SSLSocketFactory sslSocketFactory; + @SuppressWarnings("FieldCanBeLocal") private NotificationListeningDelegate notificationListeningDelegate; - private final OperationQueue sendMessageOperationQueue; + private final OperationQueue sendMessageWithAttachmentOperationQueue; private final ExponentialBackoffRepeatingScheduler scheduler; + private final HashMap> userContentMessageUidsByServer; + private final NoDuplicateOperationQueue batchSendUserContentMessageOperationQueue; + private final HashMap> protocolMessageUidsByServer; + private final NoDuplicateOperationQueue batchSendProtocolMessageOperationQueue; + private final ExponentialBackoffRepeatingScheduler batchScheduler; + private boolean initialQueueingPerformed = false; private final Object lock = new Object(); @@ -65,11 +80,23 @@ public SendMessageCoordinator(SendManagerSessionFactory sendManagerSessionFactor this.sendManagerSessionFactory = sendManagerSessionFactory; this.sslSocketFactory = sslSocketFactory; - sendMessageOperationQueue = new OperationQueue(true); - sendMessageOperationQueue.execute(1, "Engine-SendMessageCoordinator"); + sendMessageWithAttachmentOperationQueue = new OperationQueue(true); + sendMessageWithAttachmentOperationQueue.execute(1, "Engine-SendMessageCoordinator-WithAttachment"); scheduler = new ExponentialBackoffRepeatingScheduler<>(); + + userContentMessageUidsByServer = new HashMap<>(); + batchSendUserContentMessageOperationQueue = new NoDuplicateOperationQueue(); + batchSendUserContentMessageOperationQueue.execute(1, "Engine-SendMessageCoordinator-WithUserContent"); + + protocolMessageUidsByServer = new HashMap<>(); + batchSendProtocolMessageOperationQueue = new NoDuplicateOperationQueue(); + batchSendProtocolMessageOperationQueue.execute(1, "Engine-SendMessageCoordinator-Protocol"); + + batchScheduler = new ExponentialBackoffRepeatingScheduler<>(); + + awaitingIdentityReactivationOperations = new HashMap<>(); awaitingIdentityReactivationOperationsLock = new ReentrantLock(); @@ -89,7 +116,7 @@ public void initialQueueing() { try (SendManagerSession sendManagerSession = sendManagerSessionFactory.getSession()) { OutboxMessage[] outboxMessages = OutboxMessage.getAll(sendManagerSession); for (OutboxMessage outboxMessage: outboxMessages) { - queueNewSendMessageCompositeOperation(outboxMessage.getOwnedIdentity(), outboxMessage.getUid()); + queueNewSendMessageCompositeOperation(outboxMessage.getServer(), outboxMessage.getOwnedIdentity(), outboxMessage.getUid(), outboxMessage.getAttachments().length != 0, outboxMessage.isApplicationMessage()); } initialQueueingPerformed = true; } catch (SQLException e) { @@ -98,17 +125,84 @@ public void initialQueueing() { } } - private void queueNewSendMessageCompositeOperation(Identity ownedIdentity, UID messageUid) { - UploadMessageCompositeOperation op = new UploadMessageCompositeOperation(sendManagerSessionFactory, sslSocketFactory, ownedIdentity, messageUid, this, this); - sendMessageOperationQueue.queue(op); + private void queueNewSendMessageCompositeOperation(String server, Identity ownedIdentity, UID messageUid, boolean hasAttachment, boolean hasUserContent) { + if (hasAttachment || server == null) { + UploadMessageCompositeOperation op = new UploadMessageCompositeOperation(sendManagerSessionFactory, sslSocketFactory, ownedIdentity, messageUid, this::onFinishCallbackWithAttachment, this::onCancelCallbackWithAttachment); + sendMessageWithAttachmentOperationQueue.queue(op); + } else if (hasUserContent) { + if (ownedIdentity != null && messageUid != null) { + synchronized (userContentMessageUidsByServer) { + Queue queue = userContentMessageUidsByServer.get(server); + if (queue == null) { + queue = new ArrayDeque<>(); + userContentMessageUidsByServer.put(server, queue); + } + queue.add(new IdentityAndUid(ownedIdentity, messageUid)); + } + } + BatchUploadMessagesCompositeOperation op = new BatchUploadMessagesCompositeOperation(sendManagerSessionFactory, sslSocketFactory, server, true, () -> { + List messageIdentitiesAndUids = new ArrayList<>(); + synchronized (userContentMessageUidsByServer) { + Queue queue = userContentMessageUidsByServer.get(server); + if (queue != null && !queue.isEmpty()) { + do { + messageIdentitiesAndUids.add(queue.remove()); + if (messageIdentitiesAndUids.size() == Constants.MAX_UPLOAD_MESSAGE_BATCH_SIZE) { + break; + } + } while (!queue.isEmpty()); + } + } + return messageIdentitiesAndUids.toArray(new IdentityAndUid[0]); + }, this::onFinishCallbackUserContent, this::onCancelCallbackUserContent); + batchSendUserContentMessageOperationQueue.queue(op); + } else { + if (ownedIdentity != null && messageUid != null) { + synchronized (protocolMessageUidsByServer) { + Queue queue = protocolMessageUidsByServer.get(server); + if (queue == null) { + queue = new ArrayDeque<>(); + protocolMessageUidsByServer.put(server, queue); + } + queue.add(new IdentityAndUid(ownedIdentity, messageUid)); + } + } + BatchUploadMessagesCompositeOperation op = new BatchUploadMessagesCompositeOperation(sendManagerSessionFactory, sslSocketFactory, server, false, () -> { + List messageIdentitiesAndUids = new ArrayList<>(); + synchronized (protocolMessageUidsByServer) { + Queue queue = protocolMessageUidsByServer.get(server); + if (queue != null && !queue.isEmpty()) { + do { + messageIdentitiesAndUids.add(queue.remove()); + if (messageIdentitiesAndUids.size() == Constants.MAX_UPLOAD_MESSAGE_BATCH_SIZE) { + break; + } + } while (!queue.isEmpty()); + } + } + return messageIdentitiesAndUids.toArray(new IdentityAndUid[0]); + }, this::onFinishCallbackProtocol, this::onCancelCallbackProtocol); + batchSendProtocolMessageOperationQueue.queue(op); + } } private void scheduleNewSendMessageCompositeOperationQueueing(final Identity ownedIdentity, final UID messageUid) { - scheduler.schedule(new IdentityAndUid(ownedIdentity, messageUid), () -> queueNewSendMessageCompositeOperation(ownedIdentity, messageUid), "UploadMessageCompositeOperation"); + scheduler.schedule( + new IdentityAndUid(ownedIdentity, messageUid), + () -> queueNewSendMessageCompositeOperation(null, ownedIdentity, messageUid, true, true), + "UploadMessageCompositeOperation"); + } + + private void scheduleNewBatchSendMessageCompositeOperationQueueing(String server, boolean hasUserContent) { + batchScheduler.schedule( + new StringAndBoolean(server, hasUserContent), + () -> queueNewSendMessageCompositeOperation(server, null, null, false, hasUserContent), + "BatchUploadMessagesCompositeOperation"); } public void retryScheduledNetworkTasks() { scheduler.retryScheduledRunnables(); + batchScheduler.retryScheduledRunnables(); } private void waitForIdentityReactivation(Identity ownedIdentity, UID messageUid) { @@ -122,15 +216,121 @@ private void waitForIdentityReactivation(Identity ownedIdentity, UID messageUid) awaitingIdentityReactivationOperationsLock.unlock(); } - @Override - public void onFinishCallback(Operation operation) { + + + public void onFinishCallbackProtocol(Operation operation) { + String server = ((BatchUploadMessagesCompositeOperation) operation).getServer(); + List identityInactiveMessageUids = ((BatchUploadMessagesCompositeOperation) operation).getIdentityInactiveMessageUids(); + batchScheduler.clearFailedCount(new StringAndBoolean(server, false)); + + // if there are still some messages in the queue, reschedule a batch operation + synchronized (protocolMessageUidsByServer) { + Queue queue = protocolMessageUidsByServer.get(server); + if (queue != null && !queue.isEmpty()) { + queueNewSendMessageCompositeOperation(server, null, null, false, false); + } + } + + // handle message the operations couldn't because of inactive identity + for (IdentityAndUid identityAndUid : identityInactiveMessageUids) { + waitForIdentityReactivation(identityAndUid.ownedIdentity, identityAndUid.uid); + } + } + + public void onCancelCallbackProtocol(Operation operation) { + String server = ((BatchUploadMessagesCompositeOperation) operation).getServer(); + IdentityAndUid[] identityAndMessageUids = ((BatchUploadMessagesCompositeOperation) operation).getMessageIdentitiesAndUids(); + Integer rfc = operation.getReasonForCancel(); + Logger.i("BatchUploadMessagesCompositeOperation (protocol) cancelled for reason " + rfc); + if (rfc == null) { + rfc = Operation.RFC_NULL; + } + //noinspection SwitchStatementWithTooFewBranches + switch (rfc) { + case BatchUploadMessagesCompositeOperation.RFC_BATCH_TOO_LARGE: + if (identityAndMessageUids != null) { + // if the payload is too large when batching, queue each message individually + for (IdentityAndUid identityAndMessageUid : identityAndMessageUids) { + queueNewSendMessageCompositeOperation(null, identityAndMessageUid.ownedIdentity, identityAndMessageUid.uid, true, true); + } + } + break; + default: + // re-add all messageUids to the queue and schedule a new operation later + if (identityAndMessageUids != null) { + synchronized (protocolMessageUidsByServer) { + Queue queue = protocolMessageUidsByServer.get(server); + if (queue == null) { + queue = new ArrayDeque<>(); + protocolMessageUidsByServer.put(server, queue); + } + queue.addAll(Arrays.asList(identityAndMessageUids)); + } + } + scheduleNewBatchSendMessageCompositeOperationQueueing(server, false); + } + } + + public void onFinishCallbackUserContent(Operation operation) { + String server = ((BatchUploadMessagesCompositeOperation) operation).getServer(); + List identityInactiveMessageUids = ((BatchUploadMessagesCompositeOperation) operation).getIdentityInactiveMessageUids(); + batchScheduler.clearFailedCount(new StringAndBoolean(server, true)); + + // if there are still some messages in the queue, reschedule a batch operation + synchronized (userContentMessageUidsByServer) { + Queue queue = userContentMessageUidsByServer.get(server); + if (queue != null && !queue.isEmpty()) { + queueNewSendMessageCompositeOperation(server, null, null, false, true); + } + } + + // handle message the operations couldn't because of inactive identity + for (IdentityAndUid identityAndUid : identityInactiveMessageUids) { + waitForIdentityReactivation(identityAndUid.ownedIdentity, identityAndUid.uid); + } + } + + public void onCancelCallbackUserContent(Operation operation) { + String server = ((BatchUploadMessagesCompositeOperation) operation).getServer(); + IdentityAndUid[] identityAndMessageUids = ((BatchUploadMessagesCompositeOperation) operation).getMessageIdentitiesAndUids(); + Integer rfc = operation.getReasonForCancel(); + Logger.i("BatchUploadMessagesCompositeOperation (user content) cancelled for reason " + rfc); + if (rfc == null) { + rfc = Operation.RFC_NULL; + } + //noinspection SwitchStatementWithTooFewBranches + switch (rfc) { + case BatchUploadMessagesCompositeOperation.RFC_BATCH_TOO_LARGE: + if (identityAndMessageUids != null) { + // if the payload is too large when batching, queue each message individually + for (IdentityAndUid identityAndMessageUid : identityAndMessageUids) { + queueNewSendMessageCompositeOperation(null, identityAndMessageUid.ownedIdentity, identityAndMessageUid.uid, true, true); + } + } + break; + default: + // re-add all messageUids to the queue + if (identityAndMessageUids != null) { + synchronized (userContentMessageUidsByServer) { + Queue queue = userContentMessageUidsByServer.get(server); + if (queue == null) { + queue = new ArrayDeque<>(); + userContentMessageUidsByServer.put(server, queue); + } + queue.addAll(Arrays.asList(identityAndMessageUids)); + } + } + scheduleNewBatchSendMessageCompositeOperationQueueing(server, true); + } + } + + public void onFinishCallbackWithAttachment(Operation operation) { Identity ownedIdentity = ((UploadMessageCompositeOperation) operation).getOwnedIdentity(); UID messageUid = ((UploadMessageCompositeOperation) operation).getMessageUid(); scheduler.clearFailedCount(new IdentityAndUid(ownedIdentity, messageUid)); } - @Override - public void onCancelCallback(Operation operation) { + public void onCancelCallbackWithAttachment(Operation operation) { Identity ownedIdentity = ((UploadMessageCompositeOperation) operation).getOwnedIdentity(); UID messageUid = ((UploadMessageCompositeOperation) operation).getMessageUid(); Integer rfc = operation.getReasonForCancel(); @@ -152,8 +352,8 @@ public void onCancelCallback(Operation operation) { // Notification received from OutboxMessage database @Override - public void newMessageToSend(Identity ownedIdentity, UID messageUid) { - queueNewSendMessageCompositeOperation(ownedIdentity, messageUid); + public void newMessageToSend(String server, Identity ownedIdentity, UID messageUid, boolean hasAttachment, boolean hasUserContent) { + queueNewSendMessageCompositeOperation(server, ownedIdentity, messageUid, hasAttachment, hasUserContent); } class NotificationListener implements io.olvid.engine.datatypes.NotificationListener { @@ -171,7 +371,8 @@ public void callback(String notificationName, HashMap userInfo) if (messageUids != null) { awaitingIdentityReactivationOperations.remove(ownedIdentity); for (UID messageUid: messageUids) { - queueNewSendMessageCompositeOperation(ownedIdentity, messageUid); + // if unsure, queue in the traditional message upload queue, even if there is no attachment + queueNewSendMessageCompositeOperation(null, ownedIdentity, messageUid, true, true); } } awaitingIdentityReactivationOperationsLock.unlock(); diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/databases/OutboxMessage.java b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/databases/OutboxMessage.java index 70519536..b57ab48c 100644 --- a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/databases/OutboxMessage.java +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/databases/OutboxMessage.java @@ -149,13 +149,16 @@ public void setUidFromServer(UID uidFromServer, byte[] nonce, long timestampFrom // region constructors - public static OutboxMessage create(SendManagerSession sendManagerSession, Identity ownedIdentity, UID uid, String server, EncryptedBytes encryptedContent, EncryptedBytes encryptedExtendedContent, boolean isApplicationMessage, boolean isVoipMessage) { + public static OutboxMessage create(SendManagerSession sendManagerSession, Identity ownedIdentity, UID uid, String server, EncryptedBytes encryptedContent, EncryptedBytes encryptedExtendedContent, boolean isApplicationMessage, boolean isVoipMessage, boolean hasAttachments) { if (ownedIdentity == null || uid == null || server == null || encryptedContent == null) { return null; } try { OutboxMessage outboxMessage = new OutboxMessage(sendManagerSession, ownedIdentity, uid, server, encryptedContent, encryptedExtendedContent, isApplicationMessage, isVoipMessage); outboxMessage.insert(); + if (hasAttachments) { + outboxMessage.commitHookBits |= HOOK_BIT_HAS_ATTACHMENTS; + } return outboxMessage; } catch (SQLException e) { e.printStackTrace(); @@ -219,6 +222,39 @@ public static OutboxMessage get(SendManagerSession sendManagerSession, Identity } } + public static OutboxMessage[] getManyWithoutUidFromServer(SendManagerSession sendManagerSession, Identity ownedIdentity, String server, UID[] uids) throws SQLException { + if (uids == null) { + return null; + } + + // build a ?,? string + int count = uids.length; + StringBuilder sb = new StringBuilder(count * 2); + while (count-- > 1) { + sb.append("?,"); + } + sb.append("?"); + + try (PreparedStatement statement = sendManagerSession.session.prepareStatement("SELECT * FROM " + TABLE_NAME + + " WHERE " + OWNED_IDENTITY + " = ? " + + " AND " + SERVER + " = ? " + + " AND " + UID_FROM_SERVER + " IS NULL " + + " AND " + UID_ + " IN (" + sb + ");")) { + statement.setBytes(1, ownedIdentity.getBytes()); + statement.setString(2, server); + for (int i = 0; i < uids.length; i++) { + statement.setBytes(i + 3, uids[i].getBytes()); + } + try (ResultSet res = statement.executeQuery()) { + List list = new ArrayList<>(); + while (res.next()) { + list.add(new OutboxMessage(sendManagerSession, res)); + } + return list.toArray(new OutboxMessage[0]); + } + } + } + public static OutboxMessage[] getAll(SendManagerSession sendManagerSession) { try (PreparedStatement statement = sendManagerSession.session.prepareStatement("SELECT * FROM " + TABLE_NAME + ";")) { try (ResultSet res = statement.executeQuery()) { @@ -411,12 +447,13 @@ public void delete() throws SQLException { public interface NewOutboxMessageListener { - void newMessageToSend(Identity ownedIdentity, UID messageUid); + void newMessageToSend(String server, Identity ownedIdentity, UID messageUid, boolean hasAttachment, boolean hasUserContent); } private long commitHookBits = 0; private static final long HOOK_BIT_INSERT = 0x1; private static final long HOOK_BIT_ACKNOWLEDGED = 0x2; + private static final long HOOK_BIT_HAS_ATTACHMENTS = 0x4; private long acknowledgedTimestampFromSever; @@ -424,7 +461,7 @@ public interface NewOutboxMessageListener { public void wasCommitted() { if ((commitHookBits & HOOK_BIT_INSERT) != 0) { if (sendManagerSession.newOutboxMessageListener != null) { - sendManagerSession.newOutboxMessageListener.newMessageToSend(ownedIdentity, uid); + sendManagerSession.newOutboxMessageListener.newMessageToSend(server, ownedIdentity, uid, (commitHookBits & HOOK_BIT_HAS_ATTACHMENTS) != 0, isApplicationMessage); } } if ((commitHookBits & HOOK_BIT_ACKNOWLEDGED) != 0) { diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/operations/BatchUploadMessagesCompositeOperation.java b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/operations/BatchUploadMessagesCompositeOperation.java new file mode 100644 index 00000000..ab133fc0 --- /dev/null +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/operations/BatchUploadMessagesCompositeOperation.java @@ -0,0 +1,139 @@ +/* + * Olvid for Android + * Copyright © 2019-2024 Olvid SAS + * + * This file is part of Olvid for Android. + * + * Olvid is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * Olvid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Olvid. If not, see . + */ + +package io.olvid.engine.networksend.operations; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import javax.net.ssl.SSLSocketFactory; + +import io.olvid.engine.crypto.Hash; +import io.olvid.engine.crypto.Suite; +import io.olvid.engine.datatypes.Operation; +import io.olvid.engine.datatypes.OperationQueue; +import io.olvid.engine.datatypes.UID; +import io.olvid.engine.datatypes.containers.IdentityAndUid; +import io.olvid.engine.datatypes.containers.StringAndBoolean; +import io.olvid.engine.networksend.coordinators.SendAttachmentCoordinator; +import io.olvid.engine.networksend.datatypes.SendManagerSessionFactory; + + +public class BatchUploadMessagesCompositeOperation extends Operation { + // possible reasons for cancel + public static final int RFC_BATCH_TOO_LARGE = 2; + public static final int RFC_NETWORK_ERROR = 3; + + private final SendManagerSessionFactory sendManagerSessionFactory; + private final SSLSocketFactory sslSocketFactory; + private final String server; + private final SendAttachmentCoordinator.MessageBatchProvider messageBatchProvider; + private IdentityAndUid[] messageIdentitiesAndUids; + private Operation[] suboperations; + + public BatchUploadMessagesCompositeOperation(SendManagerSessionFactory sendManagerSessionFactory, SSLSocketFactory sslSocketFactory, String server, boolean userContentMessages, SendAttachmentCoordinator.MessageBatchProvider messageBatchProvider, OnFinishCallback onFinishCallback, OnCancelCallback onCancelCallback) { + super(StringAndBoolean.computeUniqueUid(server, userContentMessages), onFinishCallback, onCancelCallback); + this.sendManagerSessionFactory = sendManagerSessionFactory; + this.sslSocketFactory = sslSocketFactory; + this.server = server; + this.messageBatchProvider = messageBatchProvider; + this.messageIdentitiesAndUids = null; + this.suboperations = null; + } + + public String getServer() { + return server; + } + + public IdentityAndUid[] getMessageIdentitiesAndUids() { + return messageIdentitiesAndUids; + } + + public List getIdentityInactiveMessageUids() { + if (suboperations != null && suboperations.length > 0) { + return ((BatchUploadMessagesOperation) suboperations[0]).getIdentityInactiveMessageUids(); + } + return Collections.emptyList(); + } + + @Override + public void doCancel() { + for (Operation op: suboperations) { + op.cancel(null); + } + } + + @Override + public void doExecute() { + boolean finished = false; + try { + // first get some messageUids from the provider + this.messageIdentitiesAndUids = messageBatchProvider.getBatchOFMessageUids(); + if (messageIdentitiesAndUids.length == 0) { + suboperations = new Operation[0]; + } else { + suboperations = new Operation[messageIdentitiesAndUids.length + 1]; + + suboperations[0] = new BatchUploadMessagesOperation(sendManagerSessionFactory, sslSocketFactory, server, messageIdentitiesAndUids); + for (int i = 0; i < messageIdentitiesAndUids.length; i++) { + suboperations[i + 1] = new TryToDeleteMessageAndAttachmentsOperation(sendManagerSessionFactory, messageIdentitiesAndUids[i].ownedIdentity, messageIdentitiesAndUids[i].uid); + suboperations[i + 1].addDependency(suboperations[0]); + } + } + + // now run the suboperations + if (suboperations.length > 0) { + OperationQueue queue = new OperationQueue(); + for (Operation op : suboperations) { + queue.queue(op); + } + queue.execute(1, "BatchUploadMessagesCompositeOperation"); + queue.join(); + + if (cancelWasRequested()) { + return; + } + + for (Operation op : suboperations) { + if (op.isCancelled()) { + cancel(op.getReasonForCancel()); + return; + } + } + } + finished = true; + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (finished) { + setFinished(); + } else { + cancel(null); + processCancel(); + } + } + } + + public static UID computeUniqueUid(String server) { + Hash sha256 = Suite.getHash(Hash.SHA256); + return new UID(sha256.digest(server.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/obv_engine/engine/src/main/java/io/olvid/engine/networksend/operations/BatchUploadMessagesOperation.java b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/operations/BatchUploadMessagesOperation.java new file mode 100644 index 00000000..0ee95ef2 --- /dev/null +++ b/obv_engine/engine/src/main/java/io/olvid/engine/networksend/operations/BatchUploadMessagesOperation.java @@ -0,0 +1,246 @@ +/* + * Olvid for Android + * Copyright © 2019-2024 Olvid SAS + * + * This file is part of Olvid for Android. + * + * Olvid is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * Olvid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Olvid. If not, see . + */ + +package io.olvid.engine.networksend.operations; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.SSLSocketFactory; + +import io.olvid.engine.Logger; +import io.olvid.engine.datatypes.Identity; +import io.olvid.engine.datatypes.Operation; +import io.olvid.engine.datatypes.ServerMethod; +import io.olvid.engine.datatypes.UID; +import io.olvid.engine.datatypes.containers.IdentityAndUid; +import io.olvid.engine.encoder.Encoded; +import io.olvid.engine.networksend.databases.MessageHeader; +import io.olvid.engine.networksend.databases.OutboxMessage; +import io.olvid.engine.networksend.datatypes.SendManagerSession; +import io.olvid.engine.networksend.datatypes.SendManagerSessionFactory; + + +public class BatchUploadMessagesOperation extends Operation { + private final SendManagerSessionFactory sendManagerSessionFactory; + private final SSLSocketFactory sslSocketFactory; + private final String server; + private final IdentityAndUid[] messageIdentitiesAndUids; + private final List identityInactiveMessageUids; + + public BatchUploadMessagesOperation(SendManagerSessionFactory sendManagerSessionFactory, SSLSocketFactory sslSocketFactory, String server, IdentityAndUid[] messageIdentitiesAndUids) { + super(); + this.sendManagerSessionFactory = sendManagerSessionFactory; + this.sslSocketFactory = sslSocketFactory; + this.server = server; + this.messageIdentitiesAndUids = messageIdentitiesAndUids; + this.identityInactiveMessageUids = new ArrayList<>(); + } + + public List getIdentityInactiveMessageUids() { + return identityInactiveMessageUids; + } + + @Override + public void doCancel() { + // Nothing special to do on cancel + } + + @Override + public void doExecute() { + boolean finished = false; + try (SendManagerSession sendManagerSession = sendManagerSessionFactory.getSession()) { + try { + List outboxMessageAndHeaders = new ArrayList<>(); + + Logger.d("BatchUploadMessagesOperation uploading a batch of " + messageIdentitiesAndUids.length); + + HashMap> messageUidsByIdentity = new HashMap<>(); + for (IdentityAndUid identityAndUid : messageIdentitiesAndUids) { + List list = messageUidsByIdentity.get(identityAndUid.ownedIdentity); + if (list == null) { + list = new ArrayList<>(); + messageUidsByIdentity.put(identityAndUid.ownedIdentity, list); + } + list.add(identityAndUid.uid); + } + + for (Map.Entry> entry : messageUidsByIdentity.entrySet()) { + Identity ownedIdentity = entry.getKey(); + List messageUids = entry.getValue(); + // we need to block sending message for any inactive ownedIdentity, but, if the ownedIdentity was deleted, we should send the message + // this is required for the OwnedIdentityDeletion protocol, to inform your contacts + if (!sendManagerSession.identityDelegate.isActiveOwnedIdentity(sendManagerSession.session,ownedIdentity) + && sendManagerSession.identityDelegate.isOwnedIdentity(sendManagerSession.session, ownedIdentity)) { + for (UID messageUid : messageUids) { + identityInactiveMessageUids.add(new IdentityAndUid(ownedIdentity, messageUid)); + } + } else { + OutboxMessage[] outboxMessages = OutboxMessage.getManyWithoutUidFromServer(sendManagerSession, ownedIdentity, server, messageUids.toArray(new UID[0])); + for (OutboxMessage outboxMessage : outboxMessages) { + MessageHeader[] headers = outboxMessage.getHeaders(); + outboxMessageAndHeaders.add(new OutboxMessageAndHeaders(outboxMessage, headers)); + } + } + } + + if (cancelWasRequested()) { + return; + } + + BatchUploadMessagesServerMethod serverMethod = new BatchUploadMessagesServerMethod(server, outboxMessageAndHeaders.toArray(new OutboxMessageAndHeaders[0])); + serverMethod.setSslSocketFactory(sslSocketFactory); + + byte returnStatus = serverMethod.execute(true); + + sendManagerSession.session.startTransaction(); + switch (returnStatus) { + case ServerMethod.OK: + for (OutboxMessageAndHeaders outboxMessageAndHeader : serverMethod.getOutboxMessageAndHeaders()) { + outboxMessageAndHeader.outboxMessage.setUidFromServer(outboxMessageAndHeader.uidFromServer, outboxMessageAndHeader.nonce, outboxMessageAndHeader.timestampFromServer); + } + + finished = true; + return; + case ServerMethod.OK_WITH_MALFORMED_SERVER_RESPONSE: + // unable to parse server response and get message Uids --> finish the operation + for (OutboxMessageAndHeaders outboxMessageAndHeader : outboxMessageAndHeaders) { + outboxMessageAndHeader.outboxMessage.setUidFromServer(new UID(new byte[UID.UID_LENGTH]), new byte[0], 0); + } + finished = true; + return; + case ServerMethod.PAYLOAD_TOO_LARGE: { + cancel(BatchUploadMessagesCompositeOperation.RFC_BATCH_TOO_LARGE); + break; + } + case ServerMethod.GENERAL_ERROR: + default: + cancel(BatchUploadMessagesCompositeOperation.RFC_NETWORK_ERROR); + } + } catch (Exception e) { + e.printStackTrace(); + sendManagerSession.session.rollback(); + } finally { + if (finished) { + sendManagerSession.session.commit(); + setFinished(); + } else { + if (hasNoReasonForCancel()) { + cancel(null); + } + processCancel(); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + } +} + +class OutboxMessageAndHeaders { + final OutboxMessage outboxMessage; + final MessageHeader[] headers; + + UID uidFromServer = null; + byte[] nonce = null; + long timestampFromServer = 0; + + public OutboxMessageAndHeaders(OutboxMessage outboxMessage, MessageHeader[] headers) { + this.outboxMessage = outboxMessage; + this.headers = headers; + } +} + +class BatchUploadMessagesServerMethod extends ServerMethod { + private static final String SERVER_METHOD_PATH = "/batchUploadMessages"; + + private final String server; + private final OutboxMessageAndHeaders[] outboxMessageAndHeaders; + + + public OutboxMessageAndHeaders[] getOutboxMessageAndHeaders() { + return outboxMessageAndHeaders; + } + + BatchUploadMessagesServerMethod(String server, OutboxMessageAndHeaders[] outboxMessageAndHeaders) { + this.server = server; + this.outboxMessageAndHeaders = outboxMessageAndHeaders; + } + + @Override + protected String getServer() { + return server; + } + + @Override + protected String getServerMethod() { + return SERVER_METHOD_PATH; + } + + @Override + protected byte[] getDataToSend() { + Encoded[] encodeds = new Encoded[outboxMessageAndHeaders.length]; + for (int i=0; i + \ No newline at end of file diff --git a/obv_messenger/app/build.gradle b/obv_messenger/app/build.gradle index 792f71f2..fb80bbb2 100644 --- a/obv_messenger/app/build.gradle +++ b/obv_messenger/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId "io.olvid.messenger" minSdkVersion 21 targetSdk 34 - versionCode 233 - versionName "2.0" + versionCode 234 + versionName "2.0.1" vectorDrawables.useSupportLibrary true multiDexEnabled true resourceConfigurations += ['en', 'fr'] @@ -197,7 +197,7 @@ dependencies { implementation 'org.bitbucket.b_c:jose4j:0.9.6' // map libre integration - implementation 'org.maplibre.gl:android-sdk:10.2.0' + implementation 'org.maplibre.gl:android-sdk:10.3.0' implementation 'org.maplibre.gl:android-plugin-annotation-v9:2.0.2' final def commonmark_version = "0.21.0" @@ -227,7 +227,7 @@ dependencies { fullImplementation('com.google.http-client:google-http-client-gson:1.44.1') { exclude group: 'org.apache.httpcomponents' } - fullImplementation('com.google.api-client:google-api-client-android:2.3.0') { + fullImplementation('com.google.api-client:google-api-client-android:2.4.0') { exclude group: 'org.apache.httpcomponents' } fullImplementation('com.google.apis:google-api-services-drive:v3-rev20221219-2.0.0') { diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/discussion/location/MapViewMapLibreFragment.java b/obv_messenger/app/src/main/java/io/olvid/messenger/discussion/location/MapViewMapLibreFragment.java index 3dbe5d29..85a2f9aa 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/discussion/location/MapViewMapLibreFragment.java +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/discussion/location/MapViewMapLibreFragment.java @@ -166,7 +166,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c private void loadStyleUrls() { if (SettingsActivity.getLocationIntegration() == SettingsActivity.LocationIntegrationEnum.OSM) { List osmStyles = AppSingleton.getEngine().getOsmStyles(AppSingleton.getBytesCurrentIdentity()); - if (osmStyles == null || osmStyles.size() == 0) { + if (osmStyles == null || osmStyles.isEmpty()) { loadFallbackStyleUrl(); } else { currentStyleId = SettingsActivity.getLocationLastOsmStyleId(); @@ -225,8 +225,8 @@ public void onMapReady(@NonNull MapboxMap mapboxMap) { ((MapView) mapView).addOnDidFailLoadingMapListener((errorMessage) -> { Logger.w("OSM style not found, trying fallback style"); if (!triedStyleFallbackUrl) { - loadFallbackStyleUrl(); - mapboxMap.setStyle(new Style.Builder().fromUri(getStyleUrl()), this::onStyleLoaded); + triedStyleFallbackUrl = true; + mapboxMap.setStyle(new Style.Builder().fromUri(FALLBACK_OSM_STYLE_URL), this::onStyleLoaded); } }); } @@ -265,6 +265,9 @@ public void onStyleLoaded(Style style) { return; } + // reset this to allow reloading it when the user selects another not found style + triedStyleFallbackUrl = false; + // setup ui mapboxMap.getUiSettings().setCompassEnabled(true); Drawable compass = ContextCompat.getDrawable(activity, R.drawable.map_compass); @@ -313,6 +316,7 @@ public void onStyleLoaded(Style style) { // call parent callback if set if (onMapReadyCallback != null) { onMapReadyCallback.run(); + onMapReadyCallback = null; } } diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/main/contacts/ContactListItem.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/main/contacts/ContactListItem.kt index 53a16c7e..1189e94b 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/main/contacts/ContactListItem.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/main/contacts/ContactListItem.kt @@ -148,43 +148,39 @@ fun ContactListItem( Row(verticalAlignment = CenterVertically) { - endContent?.let { - it.invoke() - } ?: kotlin.run { - AnimatedVisibility(visible = publishedDetails) { - BoxWithConstraints( + AnimatedVisibility(visible = publishedDetails) { + BoxWithConstraints( + modifier = Modifier + .padding(end = 8.dp) + .size(width = 24.dp, height = 18.dp) + ) { + Image( modifier = Modifier - .padding(end = 8.dp) - .size(width = 24.dp, height = 18.dp) - - ) { + .fillMaxWidth() + .align(Alignment.BottomStart), + painter = painterResource(id = drawable.ic_olvid_card), + contentDescription = "" + ) + if (publishedDetailsNotification) { Image( modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomStart), - painter = painterResource(id = drawable.ic_olvid_card), - contentDescription = "" - ) - if (publishedDetailsNotification) { - Image( - modifier = Modifier - .size(maxWidth.times(0.4f)) - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp), - painter = painterResource(id = drawable.ic_dot_white_bordered), - contentDescription = stringResource( - id = string.content_description_message_status - ) + .size(maxWidth.times(0.4f)) + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp), + painter = painterResource(id = drawable.ic_dot_white_bordered), + contentDescription = stringResource( + id = string.content_description_message_status ) - } + ) } } + } - AnimatedVisibility(visible = shouldAnimateChannel) { - EstablishingChannel() - } - + AnimatedVisibility(visible = shouldAnimateChannel) { + EstablishingChannel() } + + endContent?.invoke() } } } diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/openid/KeycloakManager.java b/obv_messenger/app/src/main/java/io/olvid/messenger/openid/KeycloakManager.java index 287a9658..c9538a4c 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/openid/KeycloakManager.java +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/openid/KeycloakManager.java @@ -589,16 +589,7 @@ public void run() { try { UUID newApiKey = UUID.fromString(keycloakUserDetailsAndStuff.apiKey); if (!Objects.equals(Logger.getUuidString(newApiKey), kms.ownApiKey)) { - App.runThread(() -> { - if (AppSingleton.getEngine().registerOwnedIdentityApiKeyOnServer(identityBytesKey.bytes, newApiKey) == RegisterApiKeyResult.SUCCESS) { - try { - AppSingleton.getEngine().saveKeycloakApiKey(identityBytesKey.bytes, keycloakUserDetailsAndStuff.apiKey); - kms.ownApiKey = keycloakUserDetailsAndStuff.apiKey; - } catch (Exception e) { - e.printStackTrace(); - } - } - }); + App.runThread(() -> registerMeApiKeyOnServer(kms, identityBytesKey, newApiKey)); } } catch (Exception e) { // do nothing @@ -673,6 +664,35 @@ public void run() { }); } + private static void registerMeApiKeyOnServer(KeycloakManagerState kms, BytesKey identityBytesKey, UUID apiKey) { + // retry at most 10 times + for (int i=0; i<10; i++) { + RegisterApiKeyResult registerApiKeyResult = AppSingleton.getEngine().registerOwnedIdentityApiKeyOnServer(identityBytesKey.bytes, apiKey); + Logger.d("Registering Keycloak api key on server: " + registerApiKeyResult); + switch (registerApiKeyResult) { + case SUCCESS: + try { + AppSingleton.getEngine().saveKeycloakApiKey(identityBytesKey.bytes, Logger.getUuidString(apiKey)); + kms.ownApiKey = Logger.getUuidString(apiKey); + return; + } catch (Exception e) { + e.printStackTrace(); + } + break; + case WAIT_FOR_SERVER_SESSION: + // wait a bit, then try again + try { + Thread.sleep((3 + i) * 1000); + } catch (InterruptedException ignored) { } + break; + case INVALID_KEY: + case FAILED: + // non-retriable failure, abort + return; + } + } + } + private static class KeycloakManagerState { @NonNull final byte[] bytesOwnedIdentity; @NonNull JsonIdentityDetails identityDetails; diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/plus_button/ConfigurationScannedFragment.java b/obv_messenger/app/src/main/java/io/olvid/messenger/plus_button/ConfigurationScannedFragment.java index 8923fabb..2dde390c 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/plus_button/ConfigurationScannedFragment.java +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/plus_button/ConfigurationScannedFragment.java @@ -516,6 +516,7 @@ public void onClick(View v) { }); break; case FAILED: + case WAIT_FOR_SERVER_SESSION: new Handler(Looper.getMainLooper()).post(() -> { activateButton.setEnabled(true); activationSpinner.setVisibility(View.GONE); diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallActivity.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallActivity.kt index b40c639f..be5cad3b 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallActivity.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallActivity.kt @@ -26,7 +26,6 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager -import android.content.res.Configuration import android.graphics.Point import android.media.projection.MediaProjectionConfig import android.media.projection.MediaProjectionManager @@ -40,6 +39,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.provider.Settings +import android.view.Gravity import android.view.KeyEvent import android.view.WindowManager.LayoutParams import android.widget.Toast @@ -98,10 +98,6 @@ import io.olvid.messenger.webrtc.components.CallAction.ToggleMicrophone import io.olvid.messenger.webrtc.components.CallAction.ToggleSpeaker import io.olvid.messenger.webrtc.components.CallScreen import io.olvid.messenger.webrtc.components.enterPictureInPicture -import org.webrtc.PeerConnection -import org.webrtc.RTCStatsCollectorCallback -import org.webrtc.RTCStatsReport -import org.webrtc.VideoTrack private const val REQUEST_MEDIA_PROJECTION = 314 @@ -229,6 +225,11 @@ class WebrtcCallActivity : AppCompatActivity() { } ToggleCamera -> { + if ((webrtcCallService?.getCallParticipantsLiveData()?.value?.size ?: 0) > WebrtcPeerConnectionHolder.MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO) { + App.toast(getString(R.string.toast_message_video_calls_xxx_participants, WebrtcPeerConnectionHolder.MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO + 1), Toast.LENGTH_SHORT, Gravity.BOTTOM) + return@CallScreen + } + if ( ContextCompat.checkSelfPermission( this, @@ -255,6 +256,11 @@ class WebrtcCallActivity : AppCompatActivity() { } ShareScreen -> { + if ((webrtcCallService?.getCallParticipantsLiveData()?.value?.size ?: 0) > WebrtcPeerConnectionHolder.MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO) { + App.toast(getString(R.string.toast_message_video_calls_xxx_participants, WebrtcPeerConnectionHolder.MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO + 1), Toast.LENGTH_SHORT, Gravity.BOTTOM) + return@CallScreen + } + val width: Int val height: Int if (VERSION.SDK_INT >= VERSION_CODES.R) { @@ -303,7 +309,9 @@ class WebrtcCallActivity : AppCompatActivity() { contentColor = Color.White, onDismissRequest = onDismiss, buttons = { - Row(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, bottom = 8.dp, end = 16.dp), + Row(modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 8.dp, end = 16.dp), horizontalArrangement = Arrangement.End) { dialog.additionalButton?.let { button -> button() diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallService.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallService.kt index 38cb4eb2..f9091d78 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallService.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcCallService.kt @@ -139,6 +139,7 @@ import io.olvid.messenger.webrtc.WebrtcCallService.State.WAITING_FOR_AUDIO_PERMI import io.olvid.messenger.webrtc.WebrtcCallService.WakeLock.ALL import io.olvid.messenger.webrtc.WebrtcCallService.WakeLock.PROXIMITY import io.olvid.messenger.webrtc.WebrtcCallService.WakeLock.WIFI +import io.olvid.messenger.webrtc.WebrtcPeerConnectionHolder.Companion.MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO import io.olvid.messenger.webrtc.WebrtcPeerConnectionHolder.Companion.audioDeviceModule import io.olvid.messenger.webrtc.WebrtcPeerConnectionHolder.Companion.localScreenTrack import io.olvid.messenger.webrtc.WebrtcPeerConnectionHolder.Companion.localVideoTrack @@ -176,7 +177,6 @@ import org.webrtc.CameraVideoCapturer import org.webrtc.ScreenCapturerAndroid import org.webrtc.SurfaceTextureHelper import org.webrtc.VideoCapturer -import org.webrtc.VideoTrack import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException @@ -298,7 +298,7 @@ class WebrtcCallService : Service() { private set private var bluetoothAutoConnect = true var wiredHeadsetConnected = false - var availableAudioOutputs = if (VERSION.SDK_INT >= VERSION_CODES.M) mutableStateListOf(PHONE, LOUDSPEAKER, MUTED) else mutableStateListOf(PHONE, LOUDSPEAKER) + var availableAudioOutputs = mutableStateListOf(PHONE, LOUDSPEAKER, MUTED) private set var selectedParticipant: ByteArray? by mutableStateOf(bytesOwnedIdentity) @@ -1141,7 +1141,7 @@ class WebrtcCallService : Service() { } callParticipant.setPeerState(RINGING) if (_state == INITIALIZING_CALL || _state == BUSY) { - outgoingCallRinger!!.ring(RING) + outgoingCallRinger?.ring(RING) setState(State.RINGING) } } @@ -1166,7 +1166,7 @@ class WebrtcCallService : Service() { createLogEntry(CallLogItem.STATUS_BUSY) } if (_state == INITIALIZING_CALL) { - outgoingCallRinger!!.ring(Type.BUSY) + outgoingCallRinger?.ring(Type.BUSY) setState(BUSY) } } @@ -1181,9 +1181,8 @@ class WebrtcCallService : Service() { if (callParticipant.peerState != START_CALL_MESSAGE_SENT && callParticipant.peerState != RINGING) { return@execute } - if (_state == State.RINGING || _state == BUSY) { - outgoingCallRinger!!.stop() - } + outgoingCallRinger?.stop() + val peerSdpDescription: String = try { gunzip(gzippedPeerSdpDescription) } catch (e: IOException) { @@ -1257,8 +1256,8 @@ class WebrtcCallService : Service() { private fun hangUpCallInternal() { // notify peer that you hung up (it's not just a connection loss) sendHangedUpMessage(callParticipants.values) - if (soundPool != null && _state != CALL_ENDED) { // do not play if state is already call ended - soundPool!!.play(disconnectSound, 1f, 1f, 0, 0, 1f) + if (_state != CALL_ENDED && selectedAudioOutput != MUTED) { // do not play if state is already call ended + soundPool?.play(disconnectSound, 1f, 1f, 0, 0, 1f) } createLogEntry(CallLogItem.STATUS_MISSED) setState(CALL_ENDED) @@ -1549,6 +1548,9 @@ class WebrtcCallService : Service() { if (_state == CALL_ENDED || _state == FAILED) { return@execute } + if (callParticipant.peerState == KICKED || callParticipant.peerState == HANGED_UP || callParticipant.peerState == PeerState.FAILED) { + return@execute + } callParticipant.setPeerState(RECONNECTING) updateStateFromPeerStates() } @@ -1581,8 +1583,8 @@ class WebrtcCallService : Service() { } acquireWakeLock(WIFI) createLogEntry(CallLogItem.STATUS_SUCCESSFUL) - if (soundPool != null) { - soundPool!!.play(connectSound, 1f, 1f, 0, 0, 1f) + if (selectedAudioOutput != MUTED) { + soundPool?.play(connectSound, 1f, 1f, 0, 0, 1f) } if (callDurationTimer != null) { callDurationTimer = null @@ -1872,8 +1874,7 @@ class WebrtcCallService : Service() { if (Arrays.equals( jsonContactBytesAndName.bytesContactIdentity, bytesOwnedIdentity - ) - ) { + )) { // the received array contains the user himself continue } @@ -1943,6 +1944,7 @@ class WebrtcCallService : Service() { } // endregion + // region Setters and Getters private fun setState(state: State) { if (this._state == FAILED) { @@ -2012,7 +2014,7 @@ class WebrtcCallService : Service() { } fun toggleCamera() { - if (cameraEnabled.not()) { + if (cameraEnabled.not() && callParticipants.size <= MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO) { try { localVideoTrack?.setEnabled(true) } catch (ignored: Exception) { @@ -2055,7 +2057,7 @@ class WebrtcCallService : Service() { } fun toggleScreenShare(intent: Intent? = null) { - if (screenShareActive.not()) { + if (screenShareActive.not() && callParticipants.size <= MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO) { if (screenCapturerAndroid == null) { intent?.let { // restart the ongoing foreground service to set the correct foreground service type @@ -2200,8 +2202,13 @@ class WebrtcCallService : Service() { bluetoothHeadsetManager!!.disconnectAudio() } - if (VERSION.SDK_INT >= VERSION_CODES.M) { - audioDeviceModule?.setSpeakerMute(audioOutput == MUTED) ?: return@execute + audioDeviceModule?.setSpeakerMute(audioOutput == MUTED) + if (audioOutput == MUTED) { + outgoingCallRinger?.stop() + reconnectingStreamId?.let { + soundPool?.stop(it) + } + soundPool } when (audioOutput) { @@ -2231,6 +2238,7 @@ class WebrtcCallService : Service() { } // endregion + // region Helper methods fun shouldISendTheOfferToCallParticipant(callParticipant: CallParticipant): Boolean { return BytesKey(bytesOwnedIdentity).compareTo(BytesKey(callParticipant.bytesContactIdentity)) > 0 @@ -2240,10 +2248,6 @@ class WebrtcCallService : Service() { executor.execute(runnable) } - fun getActiveLocalVideo(): VideoTrack? { - return if (screenShareActive) localScreenTrack else if (cameraEnabled) localVideoTrack else null - } - fun getCallParticipant(bytesContactIdentity: ByteArray?): CallParticipant? { val index = callParticipantIndexes[BytesKey(bytesContactIdentity)] ?: return null return callParticipants[index] @@ -2265,6 +2269,15 @@ class WebrtcCallService : Service() { pojos.add(CallParticipantPojo(callParticipant)) } callParticipantsLiveData.postValue(pojos) + if (callParticipants.size > MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO) { + // we disable video + if (cameraEnabled) { + toggleCamera() + } + if (screenShareActive) { + toggleScreenShare() + } + } } private fun updateStateFromPeerStates() { @@ -2287,19 +2300,19 @@ class WebrtcCallService : Service() { } } if (callParticipants.size == 1 && allReconnecting) { - if (reconnectingStreamId == null) { - reconnectingStreamId = soundPool!!.play(reconnectingSound, .5f, .5f, 0, -1, 1f) + if (reconnectingStreamId == null && selectedAudioOutput != MUTED) { + reconnectingStreamId = soundPool?.play(reconnectingSound, .5f, .5f, 0, -1, 1f) } } else { - if (reconnectingStreamId != null) { - soundPool!!.stop(reconnectingStreamId!!) + reconnectingStreamId?.let { + soundPool?.stop(it) reconnectingStreamId = null } } if (allPeersAreInFinalState) { createLogEntry(CallLogItem.STATUS_MISSED) // this only create the log if it was not yet created - if (soundPool != null && _state != CALL_ENDED) { - soundPool!!.play(disconnectSound, 1f, 1f, 0, 0, 1f) + if (_state != CALL_ENDED && selectedAudioOutput != MUTED) { + soundPool?.play(disconnectSound, 1f, 1f, 0, 0, 1f) } setState(CALL_ENDED) stopThisService() @@ -2324,9 +2337,7 @@ class WebrtcCallService : Service() { } } } - if (VERSION.SDK_INT >= VERSION_CODES.M) { - audioOutputs.add(MUTED) - } + audioOutputs.add(MUTED) if (!audioOutputs.contains(selectedAudioOutput)) { selectAudioOutput(audioOutputs[0]) } @@ -2900,7 +2911,8 @@ class WebrtcCallService : Service() { } // endregion -// region Service lifecycle + + // region Service lifecycle override fun onCreate() { super.onCreate() webrtcMessageReceivedBroadcastReceiver = WebrtcMessageReceivedBroadcastReceiver() @@ -2925,31 +2937,19 @@ class WebrtcCallService : Service() { callLogItem!!.duration = callDuration.value!! App.runThread { AppDatabase.getInstance().callLogItemDao().update(callLogItem) } } - if (outgoingCallRinger != null) { - outgoingCallRinger!!.stop() - } - if (incomingCallRinger != null) { - incomingCallRinger!!.stop() - } - if (soundPool != null) { - soundPool!!.release() - } - if (videoCapturer != null) { - try { - videoCapturer!!.stopCapture() - } catch (ignored: InterruptedException) { - } - videoCapturer!!.dispose() - videoCapturer = null - } - if (screenCapturerAndroid != null) { - try { - screenCapturerAndroid!!.stopCapture() - } catch (ignored: InterruptedException) { - } - screenCapturerAndroid!!.dispose() - screenCapturerAndroid = null - } + outgoingCallRinger?.stop() + incomingCallRinger?.stop() + soundPool?.release() + try { + videoCapturer?.stopCapture() + } catch (ignored: InterruptedException) { } + videoCapturer?.dispose() + videoCapturer = null + try { + screenCapturerAndroid?.stopCapture() + } catch (ignored: InterruptedException) { } + screenCapturerAndroid?.dispose() + screenCapturerAndroid = null unregisterScreenOffReceiver() unregisterWiredHeadsetReceiver() timeoutTimer.cancel() @@ -3191,7 +3191,8 @@ class WebrtcCallService : Service() { } // endregion -// region Send messages via Oblivious channel + + // region Send messages via Oblivious channel @Throws(IOException::class) fun sendStartCallMessage( callParticipant: CallParticipant, @@ -3600,17 +3601,16 @@ ${jsonIceCandidate.sdpMLineIndex} -> ${jsonIceCandidate.sdp}""" } // endregion -// region JsonDataChannelMessages + + // region JsonDataChannelMessages private inner class DataChannelListener(private val callParticipant: CallParticipant) : DataChannelMessageListener { override fun onConnect() { executor.execute { sendDataChannelMessage(callParticipant, JsonMutedInnerMessage(microphoneMuted)) sendDataChannelMessage(callParticipant, JsonVideoSupportedInnerMessage(true)) - sendDataChannelMessage( - callParticipant, - JsonVideoSharingInnerMessage(cameraEnabled) - ) + sendDataChannelMessage(callParticipant, JsonVideoSharingInnerMessage(cameraEnabled)) + sendDataChannelMessage(callParticipant, JsonScreenSharingInnerMessage(screenShareActive)) if (isCaller) { sendDataChannelMessage( callParticipant, @@ -3744,6 +3744,7 @@ ${jsonIceCandidate.sdpMLineIndex} -> ${jsonIceCandidate.sdp}""" } // endregion + inner class CallParticipant { internal val role: Role @@ -3945,7 +3946,7 @@ ${jsonIceCandidate.sdpMLineIndex} -> ${jsonIceCandidate.sdp}""" } // device orientation change listener for screen capture on API < 34 - val orientationChangeBroadcastReceiver = object: BroadcastReceiver() { + private val orientationChangeBroadcastReceiver = object: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context?.resources?.configuration?.let { configuration -> if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT && screenWidth > screenHeight diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcPeerConnectionHolder.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcPeerConnectionHolder.kt index 4d55f6af..a0b487ec 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcPeerConnectionHolder.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/WebrtcPeerConnectionHolder.kt @@ -125,6 +125,7 @@ class WebrtcPeerConnectionHolder( companion object { + const val MAXIMUM_OTHER_PARTICIPANTS_FOR_VIDEO = 2 const val OLVID_STREAM_ID = "OlvidStreamId" const val VIDEO_STREAM_ID = "video" const val SCREENCAST_STREAM_ID = "screencast" @@ -153,10 +154,10 @@ class WebrtcPeerConnectionHolder( HashSet(mutableListOf("opus", "PCMU", "PCMA", "telephone-event", "red")) private const val ADDITIONAL_OPUS_OPTIONS = ";cbr=1" // by default send and receive are mono, no need to add "stereo=0;sprop-stereo=0" - const val FIELD_TRIAL_INTEL_VP8 = "WebRTC-IntelVP8/Enabled/" - const val FIELD_TRIAL_H264_HIGH_PROFILE = "WebRTC-H264HighProfile/Enabled/" - const val FIELD_TRIAL_H264_SIMULCAST = "WebRTC-H264Simulcast/Enabled/" - const val FIELD_TRIAL_FLEX_FEC = + private const val FIELD_TRIAL_INTEL_VP8 = "WebRTC-IntelVP8/Enabled/" + private const val FIELD_TRIAL_H264_HIGH_PROFILE = "WebRTC-H264HighProfile/Enabled/" + private const val FIELD_TRIAL_H264_SIMULCAST = "WebRTC-H264Simulcast/Enabled/" + private const val FIELD_TRIAL_FLEX_FEC = "WebRTC-FlexFEC-03/Enabled/WebRTC-FlexFEC-03-Advertised/Enabled/" var eglBase: EglBase? = null var peerConnectionFactory: PeerConnectionFactory? = null @@ -434,7 +435,7 @@ class WebrtcPeerConnectionHolder( } else { screenSender = peerConnection?.addTrack( localScreenTrack, - listOf(OLVID_STREAM_ID, SCREENCAST_STREAM_ID) + listOf(SCREENCAST_STREAM_ID) // screencast does not need to be synchronized with voice ) return true } @@ -525,33 +526,6 @@ class WebrtcPeerConnectionHolder( } } - fun restartIce() { - peerConnection?.let { peerConnection -> - if (peerConnection.signalingState() == HAVE_LOCAL_OFFER) { - // rollback to a stable set before creating the new restart offer - peerConnection.setLocalDescription( - sessionDescriptionObserver, SessionDescription( - ROLLBACK, "" - ) - ) - } else if (peerConnection.signalingState() == HAVE_REMOTE_OFFER) { - // we received a remote offer - // if we are the offer sender, rollback and send a new offer, otherwise juste wait for the answer process to finish - if (webrtcCallService.shouldISendTheOfferToCallParticipant(callParticipant)) { - peerConnection.setLocalDescription( - sessionDescriptionObserver, SessionDescription( - ROLLBACK, "" - ) - ) - } else { - return - } - } - - peerConnection.restartIce() - } - } - ////// // when reading this, have a look at // https://w3c.github.io/webrtc-pc/#rtcsignalingstate-enum @@ -1101,7 +1075,7 @@ class WebrtcPeerConnectionHolder( } override fun onSetSuccess() { - Logger.w("☎ onSetSuccess") + Logger.d("☎ onSetSuccess") // called when local or remote description are set // This automatically triggers ICE gathering or connection establishment --> nothing to do for GATHER_ONCE } diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CallScreen.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CallScreen.kt index 8cf2c3df..f7b0df24 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CallScreen.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CallScreen.kt @@ -20,12 +20,15 @@ package io.olvid.messenger.webrtc.components import android.annotation.SuppressLint +import android.content.Context import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.EaseOutExpo import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi @@ -46,6 +49,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -54,15 +58,17 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -89,7 +95,6 @@ import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -126,7 +131,6 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.switchMap import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.themeadapter.appcompat.AppCompatTheme -import io.olvid.engine.Logger import io.olvid.messenger.App import io.olvid.messenger.AppSingleton import io.olvid.messenger.R @@ -304,12 +308,13 @@ private fun BoxScope.PreCall( status: String = "", cameraEnabled: Boolean = false, videoTrack: VideoTrack?, + mirror: Boolean = true, initialViewSetup: (initialView: InitialView) -> Unit = {} ) { videoTrack?.let { if (cameraEnabled) { - VideoRenderer(modifier = Modifier.fillMaxSize(), videoTrack = it, zoomable = true, mirror = true) + VideoRenderer(modifier = Modifier.fillMaxSize(), videoTrack = it, zoomable = true, mirror = mirror) } } @@ -381,12 +386,14 @@ private fun BoxScope.PreCall( } @Composable -private fun OlvidLogo() { +private fun OlvidLogo( + large: Boolean = false +) { Box( modifier = Modifier - .size(16.dp) + .size(if (large) 48.dp else 16.dp) .background( - shape = RoundedCornerShape(4.dp), + shape = RoundedCornerShape(if (large) 12.dp else 4.dp), brush = Brush.verticalGradient( listOf( colorResource(id = R.color.olvid_gradient_light), @@ -398,7 +405,7 @@ private fun OlvidLogo() { Image( modifier = Modifier .align(Alignment.Center) - .size(14.dp), + .size(if (large) 42.dp else 14.dp), painter = painterResource(id = R.drawable.icon_olvid_no_padding), contentDescription = "Olvid" ) @@ -413,7 +420,7 @@ fun PreCallScreenPreview() { .fillMaxHeight() .background(Color.Black)) { PreCall( - "Alice Barrin", + "Alice Border", "Connexion...", false, null @@ -449,6 +456,9 @@ fun CallScreen( val iAmTheCaller = webrtcCallService?.isCaller ?: false val microphoneMuted = webrtcCallService?.getMicrophoneMuted()?.observeAsState() + val pipAspectCallback: (Context, Int, Int) -> Unit = { contextArg, width, height -> + setPictureInPictureAspectRatio(context = contextArg, width = width, height = height) + } val unfilteredContacts = AppSingleton.getCurrentIdentityLiveData().switchMap { ownedIdentity: OwnedIdentity? -> @@ -484,12 +494,12 @@ fun CallScreen( val insetsController = WindowCompat.getInsetsController(window, window.decorView) insetsController.apply { - if (fullScreenMode) { + systemBarsBehavior = if (fullScreenMode) { hide(WindowInsetsCompat.Type.navigationBars()) - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } else { show(WindowInsetsCompat.Type.navigationBars()) - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + WindowInsetsControllerCompat.BEHAVIOR_DEFAULT } } } @@ -540,7 +550,8 @@ fun CallScreen( webrtcCallService, peekHeight, onCallAction, - isPip = true + isPip = true, + pipAspectCallback = pipAspectCallback ) } } else { @@ -595,10 +606,11 @@ fun CallScreen( ) { if (callState?.value == CALL_IN_PROGRESS || callState?.value == CALL_ENDED) { VideoCallContent( - participants, - webrtcCallService, - peekHeight, - onCallAction, + participants = participants, + webrtcCallService = webrtcCallService, + peekHeight = peekHeight, + onCallAction = onCallAction, + pipAspectCallback = pipAspectCallback ) } else { var initialViewSetup: (InitialView) -> Unit by remember { @@ -607,6 +619,8 @@ fun CallScreen( var name by remember { mutableStateOf("") } + val selectedCamera by webrtcCallService.selectedCameraLiveData.observeAsState() + LaunchedEffect(callParticipants) { App.runThread { if (callParticipants?.value.orEmpty().size > 1 && webrtcCallService.bytesGroupOwnerAndUidOrIdentifier != null) { @@ -684,18 +698,20 @@ fun CallScreen( }, initialViewSetup = initialViewSetup, cameraEnabled = webrtcCallService.cameraEnabled, - videoTrack = localVideoTrack?.takeIf { callState?.value != CALL_ENDED } + videoTrack = localVideoTrack?.takeIf { callState?.value != CALL_ENDED }, + mirror = selectedCamera?.mirror == true ) } AnimatedVisibility( modifier = Modifier.align(Alignment.TopEnd), visible = !fullScreenMode, - enter = slideInVertically(), - exit = slideOutVertically() + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() ) { SpeakerToggle( modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.End)) .padding(top = 10.dp, end = 10.dp), audioOutputs = webrtcCallService.availableAudioOutputs, { audioOutput -> webrtcCallService.selectAudioOutput(audioOutput) @@ -719,7 +735,7 @@ private fun ColumnScope.CallBottomSheetContent( webrtcCallService: WebrtcCallService?, onCallAction: (CallAction) -> Unit, microphoneMuted: State?, - callState: State?, + @Suppress("UNUSED_PARAMETER") callState: State?, contact: Contact?, callDuration: State?, callParticipants: State?>?, @@ -954,158 +970,188 @@ private fun ColumnScope.CallBottomSheetContent( Spacer(modifier = Modifier.height(navigationBarHeight)) } +//@Composable +//fun BoxScope.MultiVideoCallContent( +// participants: List, +// webrtcCallService: WebrtcCallService, +// peekHeight: Dp +//) { +// val selectedCamera by webrtcCallService.selectedCameraLiveData.observeAsState() +// // multi +// Column { +// LazyRow( +// modifier = Modifier.padding( +// start = 16.dp, +// top = 8.dp, +// bottom = 10.dp +// ), +// horizontalArrangement = Arrangement.spacedBy(10.dp) +// ) { +// items(items = participants.filterNot { +// it.bytesContactIdentity.contentEquals( +// webrtcCallService.selectedParticipant +// ) +// }) { callParticipant -> +// val remoteVideoTrack = +// webrtcCallService.getCallParticipant(callParticipant.bytesContactIdentity)?.peerConnectionHolder?.remoteVideoTrack +// Card( +// modifier = Modifier +// .size(72.dp) +// .clickable { +// webrtcCallService.selectedParticipant = +// callParticipant.bytesContactIdentity +// }, +// shape = RoundedCornerShape(12.dp) +// ) { +// CallParticipant( +// callParticipant = callParticipant, +// videoTrack = remoteVideoTrack, +// audioLevel = webrtcCallService.getCallParticipant(callParticipant.bytesContactIdentity)?.peerConnectionHolder?.peerAudioLevel +// ) +// +// } +// +// } +// } +// if (webrtcCallService.selectedParticipant.contentEquals(webrtcCallService.bytesOwnedIdentity!!)) { +// if (webrtcCallService.screenShareActive) { +// ScreenShareOngoing { webrtcCallService.toggleScreenShare() } +// } else { +// CallParticipant( +// modifier = Modifier +// .clip( +// RoundedCornerShape( +// topStart = 20.dp, +// topEnd = 20.dp +// ) +// ) +// .fillMaxSize(), +// bytesOwnedIdentity = webrtcCallService.bytesOwnedIdentity, +// mirror = selectedCamera?.mirror == true, +// videoTrack = localVideoTrack.takeIf { webrtcCallService.cameraEnabled }, +// screenTrack = localScreenTrack.takeIf { webrtcCallService.screenShareActive }, +// zoomable = true, +// audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.bytesOwnedIdentity) +// ) +// } +// } else { +// val remoteVideoTrack = +// webrtcCallService.getCallParticipant(webrtcCallService.selectedParticipant)?.peerConnectionHolder?.remoteVideoTrack +// CallParticipant( +// callParticipant = CallParticipantPojo( +// webrtcCallService.getCallParticipant( +// webrtcCallService.selectedParticipant +// )!! +// ), videoTrack = remoteVideoTrack, +// zoomable = true, +// audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.selectedParticipant) +// ) +// } +// } +// if (webrtcCallService.selectedParticipant.contentEquals(webrtcCallService.bytesOwnedIdentity!!) +// .not() +// ) { +// Card( +// modifier = Modifier +// .size(120.dp) +// .align(Alignment.BottomEnd) +// .offset(y = -peekHeight) +// .clickable { +// webrtcCallService.selectedParticipant = +// webrtcCallService.bytesOwnedIdentity!! +// } +// .padding(end = 16.dp, bottom = 8.dp), +// shape = RoundedCornerShape(20.dp) +// ) { +// CallParticipant( +// bytesOwnedIdentity = webrtcCallService.bytesOwnedIdentity, +// mirror = selectedCamera?.mirror == true, +// videoTrack = localVideoTrack.takeIf { webrtcCallService.cameraEnabled }, +// screenTrack = localScreenTrack.takeIf { webrtcCallService.screenShareActive }, +// audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.bytesOwnedIdentity) +// ) +// } +// } +//} + @Composable -fun BoxScope.MultiVideoCallContent( +@OptIn(ExperimentalFoundationApi::class) +private fun AudioCallContent( participants: List, webrtcCallService: WebrtcCallService, - peekHeight: Dp + onCallAction: (CallAction) -> Unit, + isPip: Boolean = false ) { - val selectedCamera by webrtcCallService.selectedCameraLiveData.observeAsState() - // multi - Column { - LazyRow( - modifier = Modifier.padding( - start = 16.dp, - top = 8.dp, - bottom = 10.dp - ), - horizontalArrangement = Arrangement.spacedBy(10.dp) + if (isPip) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - items(items = participants.filterNot { - it.bytesContactIdentity.contentEquals( - webrtcCallService.selectedParticipant + OlvidLogo(large = true) + Spacer(modifier = Modifier.height(32.dp)) + Row (verticalAlignment = Alignment.CenterVertically) + { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(id = R.drawable.ic_phone_outgoing), + contentDescription = stringResource(id = R.string.text_ongoing_call) ) - }) { callParticipant -> - val remoteVideoTrack = - webrtcCallService.getCallParticipant(callParticipant.bytesContactIdentity)?.peerConnectionHolder?.remoteVideoTrack - Card( - modifier = Modifier - .size(72.dp) - .clickable { - webrtcCallService.selectedParticipant = - callParticipant.bytesContactIdentity - }, - shape = RoundedCornerShape(12.dp) - ) { - CallParticipant( - callParticipant = callParticipant, - videoTrack = remoteVideoTrack, - audioLevel = webrtcCallService.getCallParticipant(callParticipant.bytesContactIdentity)?.peerConnectionHolder?.peerAudioLevel - ) - - } - - } - } - if (webrtcCallService.selectedParticipant.contentEquals(webrtcCallService.bytesOwnedIdentity!!)) { - if (webrtcCallService.screenShareActive) { - ScreenShareOngoing { webrtcCallService.toggleScreenShare() } - } else { - CallParticipant( - modifier = Modifier - .clip( - RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp - ) - ) - .fillMaxSize(), - bytesOwnedIdentity = webrtcCallService.bytesOwnedIdentity, - mirror = selectedCamera?.mirror == true, - videoTrack = localVideoTrack.takeIf { webrtcCallService.cameraEnabled }, - screenTrack = localScreenTrack.takeIf { webrtcCallService.screenShareActive }, - zoomable = true, - audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.bytesOwnedIdentity) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = participants.size.toString(), + fontSize = 32.sp, + fontWeight = FontWeight.Medium, + color = Color.White ) } - } else { - val remoteVideoTrack = - webrtcCallService.getCallParticipant(webrtcCallService.selectedParticipant)?.peerConnectionHolder?.remoteVideoTrack - CallParticipant( - callParticipant = CallParticipantPojo( - webrtcCallService.getCallParticipant( - webrtcCallService.selectedParticipant - )!! - ), videoTrack = remoteVideoTrack, - zoomable = true, - audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.selectedParticipant) - ) } - } - if (webrtcCallService.selectedParticipant.contentEquals(webrtcCallService.bytesOwnedIdentity!!) - .not() - ) { - Card( - modifier = Modifier - .size(120.dp) - .align(Alignment.BottomEnd) - .offset(y = -peekHeight) - .clickable { - webrtcCallService.selectedParticipant = - webrtcCallService.bytesOwnedIdentity!! - } - .padding(end = 16.dp, bottom = 8.dp), - shape = RoundedCornerShape(20.dp) + } else { + val haptics = LocalHapticFeedback.current + LazyColumn( + modifier = Modifier.padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - CallParticipant( - bytesOwnedIdentity = webrtcCallService.bytesOwnedIdentity, - mirror = selectedCamera?.mirror == true, - videoTrack = localVideoTrack.takeIf { webrtcCallService.cameraEnabled }, - screenTrack = localScreenTrack.takeIf { webrtcCallService.screenShareActive }, - audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.bytesOwnedIdentity) - ) - } - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun AudioCallContent( - participants: List, - webrtcCallService: WebrtcCallService, - onCallAction: (CallAction) -> Unit -) { - val haptics = LocalHapticFeedback.current - LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(participants) { audioParticipant -> - var menu by remember { - mutableStateOf(false) - } - Box { - DropdownMenu( - expanded = menu, - onDismissRequest = { menu = false }) { - DropdownMenuItem(onClick = { - menu = false - webrtcCallService.callerKickParticipant( - audioParticipant.bytesContactIdentity - ) - }) { - Text(text = stringResource(id = R.string.dialog_title_webrtc_kick_participant)) - } + items(participants) { audioParticipant -> + var menu by remember { + mutableStateOf(false) } - AudioParticipant(modifier = Modifier - .padding(horizontal = 24.dp) - .combinedClickable( - onClick = { - if (audioParticipant.contact?.oneToOne == true) { - onCallAction(GoToDiscussion(audioParticipant.contact)) - } - }, - onLongClick = { - if (webrtcCallService.isCaller && participants.size > 1) { - haptics.performHapticFeedback( - HapticFeedbackType.LongPress - ) - menu = true - } + Box { + DropdownMenu( + expanded = menu, + onDismissRequest = { menu = false }) { + DropdownMenuItem(onClick = { + menu = false + webrtcCallService.callerKickParticipant( + audioParticipant.bytesContactIdentity + ) + }) { + Text(text = stringResource(id = R.string.dialog_title_webrtc_kick_participant)) } - ), - initialViewSetup = audioParticipant.initialViewSetup(), - name = audioParticipant.displayName ?: "", - isMute = audioParticipant.peerIsMuted, - state = audioParticipant.peerState, - audioLevel = webrtcCallService.getAudioLevel(audioParticipant.bytesContactIdentity)) + } + AudioParticipant(modifier = Modifier + .padding(horizontal = 4.dp) + .combinedClickable( + onClick = { + if (audioParticipant.contact?.oneToOne == true) { + onCallAction(GoToDiscussion(audioParticipant.contact)) + } + }, + onLongClick = { + if (webrtcCallService.isCaller && participants.size > 1) { + haptics.performHapticFeedback( + HapticFeedbackType.LongPress + ) + menu = true + } + } + ), + initialViewSetup = audioParticipant.initialViewSetup(), + name = audioParticipant.displayName ?: "", + isMute = audioParticipant.peerIsMuted, + state = audioParticipant.peerState, + audioLevel = webrtcCallService.getAudioLevel(audioParticipant.bytesContactIdentity)) + } } } } @@ -1118,7 +1164,8 @@ private fun VideoCallContent( webrtcCallService: WebrtcCallService, peekHeight: Dp, onCallAction: (CallAction) -> Unit, - isPip: Boolean = false + isPip: Boolean = false, + pipAspectCallback: ((Context, Int, Int) -> Unit)? = null ) { val selectedCamera by webrtcCallService.selectedCameraLiveData.observeAsState() val speakingColor = colorResource(id = R.color.olvid_gradient_light) @@ -1148,17 +1195,18 @@ private fun VideoCallContent( modifier = Modifier.fillMaxSize(), zoomable = true, audioLevel = webrtcCallService.getAudioLevel(participants.firstOrNull()?.bytesContactIdentity), - isPip = isPip + isPip = isPip, + pipAspectCallback = pipAspectCallback ) AnimatedVisibility(!isPip) { Card( modifier = Modifier - .size(120.dp) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Start)) + .padding(start = 10.dp, top = 10.dp) .clickable { webrtcCallService.selectedParticipant = webrtcCallService.bytesOwnedIdentity!! } - .padding(start = 10.dp, top = 10.dp) .border( width = 2.dp, color = borderColorOwned, @@ -1167,6 +1215,7 @@ private fun VideoCallContent( shape = RoundedCornerShape(16.dp) ) { CallParticipant( + modifier = Modifier.sizeIn(maxWidth = 120.dp, maxHeight = 120.dp), bytesOwnedIdentity = webrtcCallService.bytesOwnedIdentity, mirror = selectedCamera?.mirror == true, videoTrack = localVideoTrack.takeIf { webrtcCallService.cameraEnabled }, @@ -1187,25 +1236,28 @@ private fun VideoCallContent( zoomable = true, modifier = Modifier.fillMaxSize(), audioLevel = webrtcCallService.getAudioLevel(webrtcCallService.bytesOwnedIdentity), - isPip = isPip + isPip = isPip, + fitVideo = true ) } AnimatedVisibility(!isPip) { Card( modifier = Modifier - .size(120.dp) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Start)) + .padding(start = 10.dp, top = 10.dp) .clickable { webrtcCallService.selectedParticipant = participants.firstOrNull()?.bytesContactIdentity!! - } - .padding(start = 10.dp, top = 10.dp), + }, shape = RoundedCornerShape(16.dp) ) { CallParticipant( + modifier = Modifier.sizeIn(maxWidth = 120.dp, maxHeight = 120.dp), callParticipant = participants.firstOrNull(), videoTrack = remoteVideoTrack, screenTrack = remoteScreenTrack, - audioLevel = webrtcCallService.getAudioLevel(participants.firstOrNull()?.bytesContactIdentity) + audioLevel = webrtcCallService.getAudioLevel(participants.firstOrNull()?.bytesContactIdentity), + pipAspectCallback = pipAspectCallback ) } } @@ -1225,6 +1277,10 @@ private fun VideoCallContent( label = "borderColor", animationSpec = tween(durationMillis = 1000, easing = EaseOutExpo) ) + val context = LocalContext.current + LaunchedEffect(Unit) { + pipAspectCallback?.invoke(context, 9, 16) + } BoxWithConstraints( modifier = Modifier .fillMaxSize() @@ -1301,7 +1357,7 @@ private fun VideoCallContent( AnimatedVisibility(!isPip) { Card( modifier = Modifier - .size(120.dp) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Start)) .padding(start = 10.dp, top = 10.dp) .border( width = 2.dp, @@ -1311,6 +1367,7 @@ private fun VideoCallContent( shape = RoundedCornerShape(16.dp) ) { CallParticipant( + modifier = Modifier.sizeIn(maxWidth = 120.dp, maxHeight = 120.dp), bytesOwnedIdentity = webrtcCallService.bytesOwnedIdentity, mirror = selectedCamera?.mirror == true, videoTrack = localVideoTrack.takeIf { webrtcCallService.cameraEnabled }, @@ -1319,10 +1376,15 @@ private fun VideoCallContent( } } } else if (participants.size > 2) { + val context = LocalContext.current + LaunchedEffect(Unit) { + pipAspectCallback?.invoke(context, 9, 16) + } AudioCallContent( participants = participants, webrtcCallService = webrtcCallService, - onCallAction = onCallAction + onCallAction = onCallAction, + isPip = isPip ) //MultiVideoCallContent(participants = participants, webrtcCallService = webrtcCallService, peekHeight = peekHeight) } @@ -1346,8 +1408,11 @@ fun CallParticipant( zoomable: Boolean = false, peekHeight: Dp = 0.dp, audioLevel: Double?, - isPip: Boolean = false + isPip: Boolean = false, + pipAspectCallback: ((Context, Int, Int) -> Unit)? = null, + fitVideo: Boolean = false ) { + BoxWithConstraints(modifier = modifier) { val largeLayout = maxWidth > 200.dp val hasVideo = callParticipant?.peerVideoSharing != false && videoTrack.isEnabledSafe() @@ -1359,37 +1424,42 @@ fun CallParticipant( modifier = Modifier.fillMaxSize(), videoTrack = screenTrack!!, zoomable = zoomable, - mirror = false + mirror = false, + pipAspectCallback = pipAspectCallback, + fitVideo = true ) if (hasVideo) { Card( modifier = Modifier .align(Alignment.BottomStart) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Start)) .then( if (isPip) Modifier .offset(x = 4.dp, y = (-4).dp) - .size(60.dp) + .sizeIn(maxWidth = 60.dp, maxHeight = 60.dp) else Modifier .offset(x = 10.dp, y = -(peekHeight + 10.dp)) - .size(120.dp) + .sizeIn(maxWidth = 120.dp, maxHeight = 120.dp) ), shape = RoundedCornerShape(16.dp) ) { VideoRenderer( - modifier = Modifier.fillMaxSize(), videoTrack = videoTrack!!, - mirror = mirror + mirror = mirror, + matchVideoAspectRatio = true ) } } } else { VideoRenderer( - modifier = Modifier.fillMaxSize(), videoTrack = videoTrack!!, zoomable = zoomable, - mirror = mirror + mirror = mirror, + matchVideoAspectRatio = !largeLayout, + pipAspectCallback = pipAspectCallback, + fitVideo = fitVideo ) } callParticipant?.peerState.takeUnless { it == CONNECTED }?.let { @@ -1410,6 +1480,10 @@ fun CallParticipant( ) } } else { + val context = LocalContext.current + LaunchedEffect(Unit) { + pipAspectCallback?.invoke(context, 9, 16) + } val radius by animateFloatAsState( targetValue = audioLevel?.toFloat() ?: 0f, animationSpec = tween(durationMillis = 600), @@ -1491,8 +1565,29 @@ fun CallParticipant( ))?.let { name -> Text( modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 8.dp, bottom = peekHeight + if (hasVideo && hasScreenShare) 128.dp else 8.dp) + .then( + if (hasVideo && hasScreenShare) + Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.End + ) + ) + else + Modifier + .align(Alignment.BottomStart) + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Start + ) + ) + ) + .padding( + start = 6.dp, + bottom = peekHeight + 6.dp, + end = 6.dp + ) .background( colorResource(id = R.color.blackOverlay), RoundedCornerShape(12.dp) @@ -1727,16 +1822,17 @@ fun AudioParticipant( color = Color.LightGray ) } - AnimatedVisibility(visible = isMute) { + if(isMute) { Icon( modifier = Modifier - .size(20.dp), + .size(32.dp) + .background(colorResource(id = R.color.red), CircleShape) + .padding(4.dp), painter = painterResource(id = R.drawable.ic_microphone_off), tint = Color.White, contentDescription = "muted" ) - } - AnimatedVisibility(visible = isMute.not()) { + } else { AudioLevel( modifier = Modifier.size(32.dp), audioLevel = audioLevel diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CustomEglRenderer.java b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CustomEglRenderer.java new file mode 100644 index 00000000..6fa738b8 --- /dev/null +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/CustomEglRenderer.java @@ -0,0 +1,533 @@ +/* + * Olvid for Android + * Copyright © 2019-2024 Olvid SAS + * + * This file is part of Olvid for Android. + * + * Olvid is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * Olvid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Olvid. If not, see . + */ + +package io.olvid.messenger.webrtc.components; + + +import android.graphics.Matrix; +import android.graphics.SurfaceTexture; +import android.opengl.GLES20; +import android.view.Surface; + +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; + +import org.webrtc.EglBase; +import org.webrtc.EglThread; +import org.webrtc.GlTextureFrameBuffer; +import org.webrtc.GlUtil; +import org.webrtc.RendererCommon; +import org.webrtc.ThreadUtils; +import org.webrtc.VideoFrame; +import org.webrtc.VideoFrameDrawer; +import org.webrtc.VideoSink; + +import java.util.concurrent.CountDownLatch; + +/** + * This class is an adaptation of the EglRenderer bundled in webrtc. + * Compared to the original one, it can handle pan and zoom through the ScaleAndOffsetControl + */ +public class CustomEglRenderer implements VideoSink, ScaleAndOffsetControl.ScaleAndOffsetControlListener { + private class EglSurfaceCreation implements Runnable { + private Object surface; + + public synchronized void setSurface(Object surface) { + this.surface = surface; + } + + @Override + public synchronized void run() { + if (surface != null && eglBase != null && !eglBase.hasSurface()) { + if (surface instanceof Surface) { + eglBase.createSurface((Surface) surface); + } else if (surface instanceof SurfaceTexture) { + eglBase.createSurface((SurfaceTexture) surface); + } else { + throw new IllegalStateException("Invalid surface: " + surface); + } + eglBase.makeCurrent(); + // Necessary for YUV frames with odd width. + GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1); + } + } + } + + protected final String name; + + // `eglThread` is used for rendering, and is synchronized on `threadLock`. + private final Object threadLock = new Object(); + @GuardedBy("threadLock") @Nullable private EglThread eglThread; + + private final Runnable eglExceptionCallback = new Runnable() { + @Override + public void run() { + synchronized (threadLock) { + eglThread = null; + } + } + }; + + + // EGL and GL resources for drawing YUV/OES textures. After initialization, these are only + // accessed from the render thread. + @Nullable private EglBase eglBase; + private final VideoFrameDrawer frameDrawer; + @Nullable private RendererCommon.GlDrawer drawer; + private boolean usePresentationTimeStamp; + private final Matrix drawMatrix = new Matrix(); + + // Pending frame to render. Serves as a queue with size 1. Synchronized on `frameLock`. + private final Object frameLock = new Object(); + @Nullable private VideoFrame pendingFrame; + + // These variables are synchronized on `layoutLock`. + private final Object layoutLock = new Object(); + private float layoutAspectRatio; + // If true, mirrors the video stream horizontally. + private boolean mirrorHorizontally; + // If true, mirrors the video stream vertically. + private boolean mirrorVertically; + + + // Used for bitmap capturing. + private final GlTextureFrameBuffer bitmapTextureFramebuffer = + new GlTextureFrameBuffer(GLES20.GL_RGBA); + + private final EglSurfaceCreation eglSurfaceCreationRunnable = new EglSurfaceCreation(); + + /** + * Standard constructor. The name will be included when logging. In order to render something, + * you must first call init() and createEglSurface. + */ + public CustomEglRenderer(String name, @Nullable ScaleAndOffsetControl scaleAndOffsetControl) { + this.name = name; + this.frameDrawer = new VideoFrameDrawer(); + if (scaleAndOffsetControl != null) { + scaleAndOffsetControl.setListener(this); + } + } + + public void init( + EglThread eglThread, RendererCommon.GlDrawer drawer, boolean usePresentationTimeStamp) { + synchronized (threadLock) { + if (this.eglThread != null) { + throw new IllegalStateException(name + "Already initialized"); + } + + this.eglThread = eglThread; + this.drawer = drawer; + this.usePresentationTimeStamp = usePresentationTimeStamp; + + eglThread.addExceptionCallback(eglExceptionCallback); + + eglBase = eglThread.createEglBaseWithSharedConnection(); + eglThread.getHandler().post(eglSurfaceCreationRunnable); + } + } + + /** + * Initialize this class, sharing resources with `sharedContext`. The custom `drawer` will be used + * for drawing frames on the EGLSurface. This class is responsible for calling release() on + * `drawer`. It is allowed to call init() to reinitialize the renderer after a previous + * init()/release() cycle. If usePresentationTimeStamp is true, eglPresentationTimeANDROID will be + * set with the frame timestamps, which specifies desired presentation time and might be useful + * for e.g. syncing audio and video. + */ + public void init(@Nullable final EglBase.Context sharedContext, final int[] configAttributes, + RendererCommon.GlDrawer drawer, boolean usePresentationTimeStamp) { + EglThread thread = + EglThread.create(/* releaseMonitor= */ null, sharedContext, configAttributes); + init(thread, drawer, usePresentationTimeStamp); + } + + /** + * Same as above with usePresentationTimeStamp set to false. + * + * @see #init(EglBase.Context, int[], RendererCommon.GlDrawer, boolean) + */ + public void init(@Nullable final EglBase.Context sharedContext, final int[] configAttributes, + RendererCommon.GlDrawer drawer) { + init(sharedContext, configAttributes, drawer, /* usePresentationTimeStamp= */ false); + } + + public void createEglSurface(Surface surface) { + createEglSurfaceInternal(surface); + } + + public void createEglSurface(SurfaceTexture surfaceTexture) { + createEglSurfaceInternal(surfaceTexture); + } + + private void createEglSurfaceInternal(Object surface) { + eglSurfaceCreationRunnable.setSurface(surface); + postToRenderThread(eglSurfaceCreationRunnable); + } + + /** + * Block until any pending frame is returned and all GL resources released, even if an interrupt + * occurs. If an interrupt occurs during release(), the interrupt flag will be set. This function + * should be called before the Activity is destroyed and the EGLContext is still valid. If you + * don't call this function, the GL resources might leak. + */ + public void release() { + final CountDownLatch eglCleanupBarrier = new CountDownLatch(1); + synchronized (threadLock) { + if (eglThread == null) { + return; + } + eglThread.removeExceptionCallback(eglExceptionCallback); + + // Release EGL and GL resources on render thread. + eglThread.getHandler().postAtFrontOfQueue(() -> { + // Detach current shader program. + synchronized (EglBase.lock) { + GLES20.glUseProgram(/* program= */ 0); + } + if (drawer != null) { + drawer.release(); + drawer = null; + } + frameDrawer.release(); + bitmapTextureFramebuffer.release(); + + if (eglBase != null) { + eglBase.detachCurrent(); + eglBase.release(); + eglBase = null; + } + + eglCleanupBarrier.countDown(); + }); + + // Don't accept any more frames or messages to the render thread. + eglThread.release(); + eglThread = null; + } + // Make sure the EGL/GL cleanup posted above is executed. + ThreadUtils.awaitUninterruptibly(eglCleanupBarrier); + synchronized (frameLock) { + if (pendingFrame != null) { + pendingFrame.release(); + pendingFrame = null; + } + } + } + + + /** + * Set if the video stream should be mirrored horizontally or not. + */ + public void setMirror(final boolean mirror) { + synchronized (layoutLock) { + this.mirrorHorizontally = mirror; + } + } + + /** + * Set if the video stream should be mirrored vertically or not. + */ + public void setMirrorVertically(final boolean mirrorVertically) { + synchronized (layoutLock) { + this.mirrorVertically = mirrorVertically; + } + } + + /** + * Set layout aspect ratio. This is used to crop frames when rendering to avoid stretched video. + * Set this to 0 to disable cropping. + */ + public void setLayoutAspectRatio(float layoutAspectRatio) { + synchronized (layoutLock) { + this.layoutAspectRatio = layoutAspectRatio; + } + } + + + + // VideoSink interface. + @Override + public void onFrame(VideoFrame frame) { + synchronized (threadLock) { + if (eglThread == null) { + return; + } + synchronized (frameLock) { + if (pendingFrame != null) { + pendingFrame.release(); + } + pendingFrame = frame; + pendingFrame.retain(); + eglThread.getHandler().post(this::renderFrameOnRenderThread); + } + } + } + + /** + * Release EGL surface. This function will block until the EGL surface is released. + */ + public void releaseEglSurface(final Runnable completionCallback) { + // Ensure that the render thread is no longer touching the Surface before returning from this + // function. + eglSurfaceCreationRunnable.setSurface(null /* surface */); + synchronized (threadLock) { + if (eglThread != null) { + eglThread.getHandler().removeCallbacks(eglSurfaceCreationRunnable); + eglThread.getHandler().postAtFrontOfQueue(() -> { + if (eglBase != null) { + eglBase.detachCurrent(); + eglBase.releaseSurface(); + } + completionCallback.run(); + }); + return; + } + } + completionCallback.run(); + } + + /** + * Private helper function to post tasks safely. + */ + private void postToRenderThread(Runnable runnable) { + synchronized (threadLock) { + if (eglThread != null) { + eglThread.getHandler().post(runnable); + } + } + } + + private void clearSurfaceOnRenderThread(float r, float g, float b, float a) { + if (eglBase != null && eglBase.hasSurface()) { + eglBase.makeCurrent(); + GLES20.glClearColor(r, g, b, a); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + eglBase.swapBuffers(); + } + } + + /** + * Post a task to clear the surface to a transparent uniform color. + */ + public void clearImage() { + clearImage(0 /* red */, 0 /* green */, 0 /* blue */, 0 /* alpha */); + } + + /** + * Post a task to clear the surface to a specific color. + */ + public void clearImage(final float r, final float g, final float b, final float a) { + synchronized (threadLock) { + if (eglThread == null) { + return; + } + eglThread.getHandler().postAtFrontOfQueue(() -> clearSurfaceOnRenderThread(r, g, b, a)); + } + } + + private void swapBuffersOnRenderThread(final VideoFrame frame) { + synchronized (threadLock) { + if (eglThread != null) { + eglThread.scheduleRenderUpdate( + runsInline -> { + if (!runsInline) { + if (eglBase == null || !eglBase.hasSurface()) { + return; + } + eglBase.makeCurrent(); + } + + if (usePresentationTimeStamp) { + eglBase.swapBuffers(frame.getTimestampNs()); + } else { + eglBase.swapBuffers(); + } + }); + } + } + } + + /** + * Renders and releases `pendingFrame`. + */ + private void renderFrameOnRenderThread() { + // Fetch and render `pendingFrame`. + final VideoFrame frame; + synchronized (frameLock) { + if (pendingFrame == null) { + return; + } + frame = pendingFrame; + pendingFrame = null; + } + if (eglBase == null || !eglBase.hasSurface()) { + frame.release(); + return; + } + eglBase.makeCurrent(); + + final float frameAspectRatio = frame.getRotatedWidth() / (float) frame.getRotatedHeight(); + final float offsetRatioX; + final float offsetRatioY; + final float scaleX; + final float scaleY; + final int gapX; + final int gapY; + synchronized (layoutLock) { + if (lastFrameRatio - frameAspectRatio > 0.05 + || frameAspectRatio - lastFrameRatio > 0.05 + || lastEglWidth != eglBase.surfaceWidth() + || lastEglHeight != eglBase.surfaceHeight()) { + lastFrameRatio = frameAspectRatio; + lastEglWidth = eglBase.surfaceWidth(); + lastEglHeight = eglBase.surfaceHeight(); + coerceZoomAndOffset(); + } + offsetRatioX = this.offsetX/lastEglWidth; + offsetRatioY = this.offsetY/lastEglHeight; + scaleX = this.scaleX; + scaleY = this.scaleY; + gapX = (int) (this.gapRatioX*lastEglWidth); + gapY = (int) (this.gapRatioY*lastEglHeight); + } + + drawMatrix.reset(); + drawMatrix.preTranslate(0.5f, 0.5f); + drawMatrix.preScale(mirrorHorizontally ? -1f : 1f, mirrorVertically ? -1f : 1f); + drawMatrix.preScale(scaleX, scaleY); + drawMatrix.preTranslate(-0.5f - offsetRatioX, -0.5f + offsetRatioY); + + + try { + GLES20.glClearColor(0 /* red */, 0 /* green */, 0 /* blue */, 0 /* alpha */); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + frameDrawer.drawFrame(frame, drawer, drawMatrix, gapX, gapY, lastEglWidth - 2*gapX, lastEglHeight - 2*gapY); + + swapBuffersOnRenderThread(frame); + } catch (GlUtil.GlOutOfMemoryException e) { + // Attempt to free up some resources. + drawer.release(); + frameDrawer.release(); + bitmapTextureFramebuffer.release(); + // Continue here on purpose and retry again for next frame. In worst case, this is a + // continuous problem and no more frames will be drawn. + } finally { + frame.release(); + } + } + + float lastFrameRatio = 1f; + int lastEglWidth = 0; + int lastEglHeight = 0; + + /** + * Set to true to fit the whole image, false to fill, null for custom zoom and offset + */ + Boolean fit = false; + + /** + * 1 is the fill zoom + */ + float zoom = 1; + + /** + * Offsets are in pixels, and are converted to frame percent during frame render matrix computation + */ + float offsetX = 0; + float offsetY = 0; + + float gapRatioX = 0f; + float gapRatioY = 0f; + + float scaleX = 1f; + float scaleY = 1f; + + @Override + public void onFit() { + synchronized (layoutLock) { + fit = true; + zoom = (layoutAspectRatio > lastFrameRatio) ? lastFrameRatio / layoutAspectRatio : layoutAspectRatio / lastFrameRatio; + offsetX = 0; + offsetY = 0; + coerceZoomAndOffset(); + } + } + + @Override + public void onFill() { + synchronized (layoutLock) { + fit = false; + zoom = 1; + offsetX = 0; + offsetY = 0; + coerceZoomAndOffset(); + } + } + + @Override + public void onTransformation(float zoomChange, float offsetChangeX, float offsetChangeY) { + synchronized (layoutLock) { + fit = null; + zoom *= zoomChange; + offsetX += offsetChangeX; + offsetY += offsetChangeY; + coerceZoomAndOffset(); + } + } + + private void coerceZoomAndOffset() { + synchronized (layoutLock) { + if (fit != null) { + if (fit) { + zoom = (layoutAspectRatio > lastFrameRatio) ? lastFrameRatio / layoutAspectRatio : layoutAspectRatio / lastFrameRatio; + scaleX = layoutAspectRatio / lastFrameRatio / zoom; + scaleY = Math.min(1f/zoom, 1); + } else { + zoom = 1; + offsetX = 0; + offsetY = 0; + } + } + + float minZoom = (layoutAspectRatio > lastFrameRatio) ? lastFrameRatio / layoutAspectRatio : layoutAspectRatio / lastFrameRatio; + if (zoom < minZoom) { zoom = minZoom; } + if (zoom > 3f) { zoom = 3f; } + + if (lastFrameRatio > layoutAspectRatio) { + scaleX = layoutAspectRatio / lastFrameRatio / zoom; + scaleY = Math.min(1f/zoom, 1); + gapRatioX = 0; + gapRatioY = Math.max(0, .5f-.5f*zoom); + float maxOffsetX = (1 - minZoom/zoom)*lastEglWidth/2; + if (offsetX > maxOffsetX) { offsetX = maxOffsetX; } + if (offsetX < -maxOffsetX) { offsetX = -maxOffsetX; } + offsetY = 0; + } else { + scaleX = Math.min(1f/zoom, 1); + scaleY = lastFrameRatio / layoutAspectRatio / zoom; + gapRatioX = Math.max(0, .5f-.5f*zoom); + gapRatioY = 0; + offsetX = 0; + float maxOffsetY = (1 - minZoom/zoom)*lastEglHeight/2; + if (offsetY > maxOffsetY) { offsetY = maxOffsetY; } + if (offsetY < -maxOffsetY) { offsetY = -maxOffsetY; } + } + } + } +} diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/PictureInPicture.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/PictureInPicture.kt index 64109193..cd02158f 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/PictureInPicture.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/PictureInPicture.kt @@ -33,9 +33,8 @@ import io.olvid.messenger.R internal fun enterPictureInPicture(context: Context) { if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val aspect = Rational(9, 16) // TODO: use incoming video aspect ratio? val params = PictureInPictureParams.Builder() - .setAspectRatio(aspect).apply { + .setAspectRatio(pipAspect).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setAutoEnterEnabled(false) } @@ -51,6 +50,17 @@ internal fun enterPictureInPicture(context: Context) { } } +internal var pipAspect = Rational(9, 16) + +internal fun setPictureInPictureAspectRatio(context: Context, width: Int, height: Int) { + pipAspect = Rational(width, height) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.findActivity()?.setPictureInPictureParams(PictureInPictureParams.Builder() + .setAspectRatio(pipAspect) + .build()) + } +} + internal val Context.isInPictureInPictureMode: Boolean get() { val currentActivity = findActivity() diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/ScaleAndOffsetControl.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/ScaleAndOffsetControl.kt new file mode 100644 index 00000000..549a6c02 --- /dev/null +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/ScaleAndOffsetControl.kt @@ -0,0 +1,45 @@ +/* + * Olvid for Android + * Copyright © 2019-2024 Olvid SAS + * + * This file is part of Olvid for Android. + * + * Olvid is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * Olvid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Olvid. If not, see . + */ + +package io.olvid.messenger.webrtc.components + +import androidx.compose.ui.geometry.Offset + + +class ScaleAndOffsetControl { + interface ScaleAndOffsetControlListener { + fun onFit() + fun onFill() + fun onTransformation(zoomChange: Float, offsetChangeX: Float, offsetChangeY: Float) + } + + var listener: ScaleAndOffsetControlListener? = null + + fun setFit() { + listener?.onFit() + } + + fun setFill() { + listener?.onFill() + } + + fun applyTransformation(zoomChange: Float, offsetChange: Offset) { + listener?.onTransformation(zoomChange, offsetChange.x, offsetChange.y) + } +} \ No newline at end of file diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoRenderer.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoRenderer.kt index ca03a3ef..2330ce93 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoRenderer.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoRenderer.kt @@ -19,9 +19,11 @@ package io.olvid.messenger.webrtc.components +import android.content.Context import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable -import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -31,11 +33,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.viewinterop.AndroidView -import io.olvid.engine.Logger import io.olvid.messenger.webrtc.WebrtcPeerConnectionHolder import org.webrtc.RendererCommon import org.webrtc.VideoTrack @@ -51,9 +49,11 @@ fun VideoRenderer( modifier: Modifier = Modifier, videoTrack: VideoTrack, zoomable: Boolean = false, - mirror: Boolean = false + mirror: Boolean = false, + matchVideoAspectRatio: Boolean = false, + pipAspectCallback: ((Context, Int, Int) -> Unit)? = null, + fitVideo: Boolean = false ) { - val density = LocalDensity.current val trackState: MutableState = remember { mutableStateOf(null) } var view: VideoTextureViewRenderer? by remember { mutableStateOf(null) } @@ -62,30 +62,18 @@ fun VideoRenderer( cleanTrack(view, trackState) } } - var scale by remember { mutableFloatStateOf(1f) } - var offset by remember { mutableStateOf(Offset.Zero) } - BoxWithConstraints(modifier = modifier) { - val width = with(density) { - maxWidth.toPx() - } - val height = with(density) { - maxHeight.toPx() - } - - val maxX = (scale - 1) * width / 2 - val maxY = (scale - 1) * height / 2 + val scaleAndOffsetControl = remember { ScaleAndOffsetControl() } + var videoAspectRatio by remember { mutableFloatStateOf(1f) } + Box(modifier = modifier) { val state = rememberTransformableState { zoomChange, offsetChange, _ -> - scale = (scale * zoomChange).coerceIn(1f, 3f) - offset = Offset( - x = (offset.x + offsetChange.x * scale).coerceIn(-maxX, maxX), - y = (offset.y + offsetChange.y * scale).coerceIn(-maxY, maxY) - ) + scaleAndOffsetControl.applyTransformation(zoomChange = zoomChange, offsetChange = offsetChange) } + AndroidView( factory = { context -> - VideoTextureViewRenderer(context).apply { + VideoTextureViewRenderer(context, scaleAndOffsetControl).apply { WebrtcPeerConnectionHolder.eglBase?.eglBaseContext?.let { init( it, @@ -93,14 +81,19 @@ fun VideoRenderer( object : RendererCommon.RendererEvents { override fun onFirstFrameRendered() = Unit - override fun onFrameResolutionChanged(p0: Int, p1: Int, p2: Int) { - // TODO: adjust min scale with this - Logger.d("onFrameResolutionChanged ${p0}x${p1} - $p2") + override fun onFrameResolutionChanged(width: Int, height: Int, rotation: Int) { + videoAspectRatio = width / height.toFloat() + pipAspectCallback?.invoke(context, width, height) } } ) } setupVideo(trackState, videoTrack, this) + if (fitVideo) { + scaleAndOffsetControl.setFit() + } else { + scaleAndOffsetControl.setFill() + } view = this } }, @@ -108,18 +101,16 @@ fun VideoRenderer( setupVideo(trackState, videoTrack, v) v.setMirror(mirror) }, - modifier = modifier.then( - if (zoomable) { - Modifier - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offset.x, - translationY = offset.y - ) - .transformable(state = state) - } else Modifier - ) + modifier = modifier + .then( + if (matchVideoAspectRatio) { + Modifier.aspectRatio(videoAspectRatio) + } else Modifier + ).then( + if (zoomable) { + Modifier.transformable(state = state) + } else Modifier + ) ) } } diff --git a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoTextureViewRenderer.kt b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoTextureViewRenderer.kt index 2abf315b..9a57151a 100644 --- a/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoTextureViewRenderer.kt +++ b/obv_messenger/app/src/main/java/io/olvid/messenger/webrtc/components/VideoTextureViewRenderer.kt @@ -28,7 +28,6 @@ import android.util.AttributeSet import android.view.TextureView import android.view.TextureView.SurfaceTextureListener import org.webrtc.EglBase -import org.webrtc.EglRenderer import org.webrtc.GlRectDrawer import org.webrtc.RendererCommon.RendererEvents import org.webrtc.ThreadUtils @@ -41,6 +40,7 @@ import java.util.concurrent.CountDownLatch */ class VideoTextureViewRenderer @JvmOverloads constructor( context: Context, + val scaleAndOffsetControl: ScaleAndOffsetControl? = null, attrs: AttributeSet? = null ) : TextureView(context, attrs), VideoSink, SurfaceTextureListener { @@ -52,7 +52,7 @@ class VideoTextureViewRenderer @JvmOverloads constructor( /** * Renderer used to render the video. */ - private val eglRenderer: EglRenderer = EglRenderer(resourceName) + private val eglRenderer: CustomEglRenderer = CustomEglRenderer(resourceName, scaleAndOffsetControl) /** * Callback used for reporting render events. diff --git a/obv_messenger/app/src/main/res/drawable/olvid_icon.xml b/obv_messenger/app/src/main/res/drawable/olvid_icon.xml new file mode 100644 index 00000000..d00083cc --- /dev/null +++ b/obv_messenger/app/src/main/res/drawable/olvid_icon.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/obv_messenger/app/src/main/res/layout/activity_webrtc_incoming_call.xml b/obv_messenger/app/src/main/res/layout/activity_webrtc_incoming_call.xml index 55dbabd9..0742d084 100644 --- a/obv_messenger/app/src/main/res/layout/activity_webrtc_incoming_call.xml +++ b/obv_messenger/app/src/main/res/layout/activity_webrtc_incoming_call.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@drawable/background_gradient_olvid" + android:background="@color/black" tools:context=".webrtc.WebrtcIncomingCallActivity"> @@ -30,14 +30,14 @@ android:id="@+id/portrait_color_circle_image_view" android:layout_width="192dp" android:layout_height="192dp" - app:layout_constraintTop_toTopOf="@id/contact_initial_view" - app:layout_constraintBottom_toBottomOf="@id/contact_initial_view" - app:layout_constraintStart_toStartOf="@id/contact_initial_view" - app:layout_constraintEnd_toEndOf="@id/contact_initial_view" android:src="@drawable/initial_view_color_circle" android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@id/contact_initial_view" + app:layout_constraintEnd_toEndOf="@id/contact_initial_view" + app:layout_constraintStart_toStartOf="@id/contact_initial_view" + app:layout_constraintTop_toTopOf="@id/contact_initial_view" tools:visibility="visible" - /> + android:importantForAccessibility="no" /> + tools:ignore="ImageContrastCheck" /> + + + + app:layout_constraintStart_toEndOf="@id/encrypted_call_logo" + app:layout_constraintTop_toTopOf="parent" /> \ No newline at end of file diff --git a/obv_messenger/app/src/main/res/values-fr/strings.xml b/obv_messenger/app/src/main/res/values-fr/strings.xml index 06b06aba..706b2194 100644 --- a/obv_messenger/app/src/main/res/values-fr/strings.xml +++ b/obv_messenger/app/src/main/res/values-fr/strings.xml @@ -1161,7 +1161,7 @@ Ajouter un contact Importer une configuration ou une licence Choisissez le profil qui sera géré par le fournisseur d\'identités de votre entreprise - Choisissez le profil auquel appliquer cette configuration/license + Choisissez le profil auquel appliquer cette configuration/licence Choisissez le profil à connecter au client web Olvid Choisissez le profil avec lequel inviter ce contact Veuillez d\'abord créer un profil, puis ouvrir à nouveau le lien @@ -1459,8 +1459,8 @@ Partager la position pendant 1 heure 3\u00a0heures Partager la position pendant 3 heures - Pas de limite - Partager la position indéfiniement + Indéfiniment + Partager la position indéfiniment Précis Équilibré Économie d\'énergie @@ -1611,7 +1611,7 @@ Émettre des appels sécurisés\n(disponible grâce à un autre profil) Ouvrir ce lien dans votre navigateur\u00a0? Impossible d\'ouvrir l\'URL - Choisir un service de cartographie vous permet de séléctionner précisément la position que vous souhaitez partager sur une carte. Toutefois, cela peut divulguer votre position (ou celle de vos contacts) à ce fournisseur de service. + Choisir un service de cartographie vous permet de sélectionner précisément la position que vous souhaitez partager sur une carte. Toutefois, cela peut divulguer votre position (ou celle de vos contacts) à ce fournisseur de service. Ce choix peut être modifié dans les paramètres de l\'application. Choisir un service Partage en direct @@ -1709,7 +1709,7 @@ Désactivation de votre appareil Votre appareil Olvid sera désactivé d\'ici %1$s Ajouter un appareil ? - Ajouter un appareil permet de facilement transférer votre profil vers un nouvel appareil ou, si vous avez une license multi-appareils, de recevoir vos messages simultanément sur les deux. **N\'ajoutez que des appareils auxquels vous faites confiance !**\n\nVérifiez que vous avez une **connexion internet stable** sur vos deux appareils avant de poursuivre. + Ajouter un appareil permet de facilement transférer votre profil vers un nouvel appareil ou, si vous avez une licence multi-appareils, de recevoir vos messages simultanément sur les deux. **N\'ajoutez que des appareils auxquels vous faites confiance !**\n\nVérifiez que vous avez une **connexion internet stable** sur vos deux appareils avant de poursuivre. Import de votre profil réussi Si vous avez plusieurs profils, peut-être souhaitez-vous les importer également ? Importer un autre profil @@ -1855,6 +1855,7 @@ Résolution en envoi des appels vidéo Appel en cours : %1$s Aucun son + Les appels vidéo sont limités à %1$d participants Vous êtes sur le point d\'inviter chaque membre de ce groupe avec lequel vous avez un canal (actuellement 1 membre) à une discussion privée.\nSouhaitez-vous poursuivre ? diff --git a/obv_messenger/app/src/main/res/values/strings.xml b/obv_messenger/app/src/main/res/values/strings.xml index 928883d9..7bbd2a28 100644 --- a/obv_messenger/app/src/main/res/values/strings.xml +++ b/obv_messenger/app/src/main/res/values/strings.xml @@ -1889,6 +1889,7 @@ 1080p Ongoing call: %1$s No output + Video calls are limited to %1$d participants diff --git a/obv_messenger/build.gradle b/obv_messenger/build.gradle index 1133c0e6..86e7e5de 100644 --- a/obv_messenger/build.gradle +++ b/obv_messenger/build.gradle @@ -7,7 +7,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.3.0' - classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.7.6' + classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.7.7' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6' // protobuf plugin for Web Client @@ -15,7 +15,7 @@ buildscript { // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files - classpath 'com.google.gms:google-services:4.4.0' + classpath 'com.google.gms:google-services:4.4.1' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20' } }