diff --git a/.idea/dictionaries/briar.xml b/.idea/dictionaries/briar.xml index 71bcd22b4..7eb591723 100644 --- a/.idea/dictionaries/briar.xml +++ b/.idea/dictionaries/briar.xml @@ -7,6 +7,7 @@ encrypter identicon introducee + introducees introducer onboarding diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java index daf9858db..f0acbd8e1 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java @@ -9,4 +9,5 @@ public interface FeatureFlags { boolean shouldEnableProfilePictures(); + boolean shouldEnableDisappearingMessages(); } diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/CleanupHook.java b/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/CleanupHook.java new file mode 100644 index 000000000..6dca7a224 --- /dev/null +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/CleanupHook.java @@ -0,0 +1,29 @@ +package org.briarproject.bramble.api.cleanup; + +import org.briarproject.bramble.api.db.DatabaseComponent; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.db.Transaction; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.GroupId; +import org.briarproject.bramble.api.sync.MessageId; + +import java.util.Collection; + +/** + * An interface for registering a hook with the {@link CleanupManager} + * that will be called when a message's cleanup deadline is reached. + */ +@NotNullByDefault +public interface CleanupHook { + + /** + * Called when the cleanup deadlines of one or more messages are reached. + *

+ * The callee is not required to delete the messages, but the hook won't be + * called again for these messages unless another cleanup timer is set (see + * {@link DatabaseComponent#setCleanupTimerDuration(Transaction, MessageId, long)} + * and {@link DatabaseComponent#startCleanupTimer(Transaction, MessageId)}). + */ + void deleteMessages(Transaction txn, GroupId g, + Collection messageIds) throws DbException; +} diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/CleanupManager.java b/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/CleanupManager.java new file mode 100644 index 000000000..d416d8fed --- /dev/null +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/CleanupManager.java @@ -0,0 +1,42 @@ +package org.briarproject.bramble.api.cleanup; + +import org.briarproject.bramble.api.cleanup.event.CleanupTimerStartedEvent; +import org.briarproject.bramble.api.crypto.SecretKey; +import org.briarproject.bramble.api.db.DatabaseComponent; +import org.briarproject.bramble.api.db.Transaction; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.ClientId; +import org.briarproject.bramble.api.sync.MessageId; + +/** + * The CleanupManager is responsible for tracking the cleanup deadlines of + * messages and passing them to their respective + * {@link CleanupHook CleanupHooks} when the deadlines are reached. + *

+ * The CleanupManager responds to + * {@link CleanupTimerStartedEvent CleanupTimerStartedEvents} broadcast by the + * {@link DatabaseComponent}. + *

+ * See {@link DatabaseComponent#setCleanupTimerDuration(Transaction, MessageId, long)}, + * {@link DatabaseComponent#startCleanupTimer(Transaction, MessageId)}, + * {@link DatabaseComponent#stopCleanupTimer(Transaction, MessageId)}. + */ +@NotNullByDefault +public interface CleanupManager { + + /** + * When scheduling a cleanup task we overshoot the deadline by this many + * milliseconds to reduce the number of tasks that need to be scheduled + * when messages have cleanup deadlines that are close together. + */ + long BATCH_DELAY_MS = 1000; + + /** + * Registers a hook to be called when messages are due for cleanup. + * This method should be called before + * {@link LifecycleManager#startServices(SecretKey)}. + */ + void registerCleanupHook(ClientId c, int majorVersion, + CleanupHook hook); +} diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/event/CleanupTimerStartedEvent.java b/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/event/CleanupTimerStartedEvent.java new file mode 100644 index 000000000..f941cd806 --- /dev/null +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/cleanup/event/CleanupTimerStartedEvent.java @@ -0,0 +1,32 @@ +package org.briarproject.bramble.api.cleanup.event; + +import org.briarproject.bramble.api.event.Event; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.MessageId; + +import javax.annotation.concurrent.Immutable; + +/** + * An event that is broadcast when a message's cleanup timer is started. + */ +@Immutable +@NotNullByDefault +public class CleanupTimerStartedEvent extends Event { + + private final MessageId messageId; + private final long cleanupDeadline; + + public CleanupTimerStartedEvent(MessageId messageId, + long cleanupDeadline) { + this.messageId = messageId; + this.cleanupDeadline = cleanupDeadline; + } + + public MessageId getMessageId() { + return messageId; + } + + public long getCleanupDeadline() { + return cleanupDeadline; + } +} diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/client/ClientHelper.java b/bramble-api/src/main/java/org/briarproject/bramble/api/client/ClientHelper.java index df68d7933..c04454d30 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/client/ClientHelper.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/client/ClientHelper.java @@ -1,6 +1,7 @@ package org.briarproject.bramble.api.client; import org.briarproject.bramble.api.FormatException; +import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.crypto.PrivateKey; import org.briarproject.bramble.api.crypto.PublicKey; import org.briarproject.bramble.api.data.BdfDictionary; @@ -16,6 +17,7 @@ import org.briarproject.bramble.api.sync.Message; import org.briarproject.bramble.api.sync.MessageId; import java.security.GeneralSecurityException; +import java.util.Collection; import java.util.Map; @NotNullByDefault @@ -50,9 +52,11 @@ public interface ClientHelper { BdfDictionary getGroupMetadataAsDictionary(Transaction txn, GroupId g) throws DbException, FormatException; + Collection getMessageIds(Transaction txn, GroupId g, + BdfDictionary query) throws DbException, FormatException; + BdfDictionary getMessageMetadataAsDictionary(MessageId m) - throws DbException, - FormatException; + throws DbException, FormatException; BdfDictionary getMessageMetadataAsDictionary(Transaction txn, MessageId m) throws DbException, FormatException; @@ -119,4 +123,17 @@ public interface ClientHelper { Map parseAndValidateTransportPropertiesMap( BdfDictionary properties) throws FormatException; + /** + * Retrieves the contact ID from the group metadata of the given contact + * group. + */ + ContactId getContactId(Transaction txn, GroupId contactGroupId) + throws DbException, FormatException; + + /** + * Stores the given contact ID in the group metadata of the given contact + * group. + */ + void setContactId(Transaction txn, GroupId contactGroupId, ContactId c) + throws DbException; } diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/client/ContactGroupConstants.java b/bramble-api/src/main/java/org/briarproject/bramble/api/client/ContactGroupConstants.java new file mode 100644 index 000000000..4c556bbe7 --- /dev/null +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/client/ContactGroupConstants.java @@ -0,0 +1,9 @@ +package org.briarproject.bramble.api.client; + +public interface ContactGroupConstants { + + /** + * Group metadata key for associating a contact ID with a contact group. + */ + String GROUP_KEY_CONTACT_ID = "contactId"; +} diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/db/DatabaseComponent.java b/bramble-api/src/main/java/org/briarproject/bramble/api/db/DatabaseComponent.java index 7c525bcd3..b5cf1d617 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/db/DatabaseComponent.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/db/DatabaseComponent.java @@ -41,6 +41,18 @@ import javax.annotation.Nullable; @NotNullByDefault public interface DatabaseComponent extends TransactionManager { + /** + * Return value for {@link #getNextCleanupDeadline(Transaction)} if + * no messages are scheduled to be deleted. + */ + long NO_CLEANUP_DEADLINE = -1; + + /** + * Return value for {@link #startCleanupTimer(Transaction, MessageId)} + * if the cleanup timer was not started. + */ + long TIMER_NOT_STARTED = -1; + /** * Opens the database and returns true if the database already existed. * @@ -288,6 +300,16 @@ public interface DatabaseComponent extends TransactionManager { Collection getMessageIds(Transaction txn, GroupId g) throws DbException; + /** + * Returns the IDs of any delivered messages in the given group with + * metadata that matches all entries in the given query. If the query is + * empty, the IDs of all delivered messages are returned. + *

+ * Read-only. + */ + Collection getMessageIds(Transaction txn, GroupId g, + Metadata query) throws DbException; + /** * Returns the IDs of any messages that need to be validated. *

@@ -314,6 +336,15 @@ public interface DatabaseComponent extends TransactionManager { Collection getMessagesToShare(Transaction txn) throws DbException; + /** + * Returns the IDs of any messages of any messages that are due for + * deletion, along with their group IDs. + *

+ * Read-only. + */ + Map> getMessagesToDelete(Transaction txn) + throws DbException; + /** * Returns the metadata for all delivered messages in the given group. *

@@ -395,6 +426,15 @@ public interface DatabaseComponent extends TransactionManager { MessageStatus getMessageStatus(Transaction txn, ContactId c, MessageId m) throws DbException; + /** + * Returns the next time (in milliseconds since the Unix epoch) when a + * message is due to be deleted, or {@link #NO_CLEANUP_DEADLINE} + * if no messages are scheduled to be deleted. + *

+ * Read-only. + */ + long getNextCleanupDeadline(Transaction txn) throws DbException; + /* * Returns the next time (in milliseconds since the Unix epoch) when a * message is due to be sent to the given contact. The returned value may @@ -535,6 +575,13 @@ public interface DatabaseComponent extends TransactionManager { void removeTransportKeys(Transaction txn, TransportId t, KeySetId k) throws DbException; + /** + * Sets the cleanup timer duration for the given message. This does not + * start the message's cleanup timer. + */ + void setCleanupTimerDuration(Transaction txn, MessageId m, long duration) + throws DbException; + /** * Marks the given contact as verified. */ @@ -557,6 +604,12 @@ public interface DatabaseComponent extends TransactionManager { */ void setMessagePermanent(Transaction txn, MessageId m) throws DbException; + /** + * Marks the given message as not shared. This method is only meant for + * testing. + */ + void setMessageNotShared(Transaction txn, MessageId m) throws DbException; + /** * Marks the given message as shared. */ @@ -599,6 +652,22 @@ public interface DatabaseComponent extends TransactionManager { void setTransportKeysActive(Transaction txn, TransportId t, KeySetId k) throws DbException; + /** + * Starts the cleanup timer for the given message, if a timer duration + * has been set and the timer has not already been started. + * + * @return The cleanup deadline, or {@link #TIMER_NOT_STARTED} if no + * timer duration has been set for this message or its timer has already + * been started. + */ + long startCleanupTimer(Transaction txn, MessageId m) throws DbException; + + /** + * Stops the cleanup timer for the given message, if the timer has been + * started. + */ + void stopCleanupTimer(Transaction txn, MessageId m) throws DbException; + /** * Stores the given transport keys, deleting any keys they have replaced. */ diff --git a/bramble-api/src/main/java/org/briarproject/bramble/util/ValidationUtils.java b/bramble-api/src/main/java/org/briarproject/bramble/util/ValidationUtils.java index fffacae0c..346edc1f7 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/util/ValidationUtils.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/util/ValidationUtils.java @@ -6,7 +6,9 @@ import org.briarproject.bramble.api.data.BdfList; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +@Immutable @NotNullByDefault public class ValidationUtils { @@ -64,4 +66,9 @@ public class ValidationUtils { if (dictionary != null && dictionary.size() != size) throw new FormatException(); } + + public static void checkRange(@Nullable Long l, long min, long max) + throws FormatException { + if (l != null && (l < min || l > max)) throw new FormatException(); + } } diff --git a/bramble-api/src/test/java/org/briarproject/bramble/test/TimeTravel.java b/bramble-api/src/test/java/org/briarproject/bramble/test/TimeTravel.java new file mode 100644 index 000000000..2287fe6c5 --- /dev/null +++ b/bramble-api/src/test/java/org/briarproject/bramble/test/TimeTravel.java @@ -0,0 +1,8 @@ +package org.briarproject.bramble.test; + +public interface TimeTravel { + + void setCurrentTimeMillis(long now) throws InterruptedException; + + void addCurrentTimeMillis(long add) throws InterruptedException; +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java b/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java index ac13a5612..c256759ff 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreEagerSingletons.java @@ -1,5 +1,6 @@ package org.briarproject.bramble; +import org.briarproject.bramble.cleanup.CleanupModule; import org.briarproject.bramble.contact.ContactModule; import org.briarproject.bramble.crypto.CryptoExecutorModule; import org.briarproject.bramble.db.DatabaseExecutorModule; @@ -14,6 +15,8 @@ import org.briarproject.bramble.versioning.VersioningModule; public interface BrambleCoreEagerSingletons { + void inject(CleanupModule.EagerSingletons init); + void inject(ContactModule.EagerSingletons init); void inject(CryptoExecutorModule.EagerSingletons init); @@ -39,6 +42,7 @@ public interface BrambleCoreEagerSingletons { class Helper { public static void injectEagerSingletons(BrambleCoreEagerSingletons c) { + c.inject(new CleanupModule.EagerSingletons()); c.inject(new ContactModule.EagerSingletons()); c.inject(new CryptoExecutorModule.EagerSingletons()); c.inject(new DatabaseExecutorModule.EagerSingletons()); diff --git a/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreModule.java b/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreModule.java index 473df2b77..447bd5cb6 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreModule.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/BrambleCoreModule.java @@ -1,5 +1,6 @@ package org.briarproject.bramble; +import org.briarproject.bramble.cleanup.CleanupModule; import org.briarproject.bramble.client.ClientModule; import org.briarproject.bramble.connection.ConnectionModule; import org.briarproject.bramble.contact.ContactModule; @@ -21,15 +22,14 @@ import org.briarproject.bramble.rendezvous.RendezvousModule; import org.briarproject.bramble.settings.SettingsModule; import org.briarproject.bramble.sync.SyncModule; import org.briarproject.bramble.sync.validation.ValidationModule; -import org.briarproject.bramble.system.ClockModule; import org.briarproject.bramble.transport.TransportModule; import org.briarproject.bramble.versioning.VersioningModule; import dagger.Module; @Module(includes = { + CleanupModule.class, ClientModule.class, - ClockModule.class, ConnectionModule.class, ContactModule.class, CryptoModule.class, diff --git a/bramble-core/src/main/java/org/briarproject/bramble/cleanup/CleanupManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/cleanup/CleanupManagerImpl.java new file mode 100644 index 000000000..4c88e57c7 --- /dev/null +++ b/bramble-core/src/main/java/org/briarproject/bramble/cleanup/CleanupManagerImpl.java @@ -0,0 +1,159 @@ +package org.briarproject.bramble.cleanup; + +import org.briarproject.bramble.api.cleanup.CleanupHook; +import org.briarproject.bramble.api.cleanup.CleanupManager; +import org.briarproject.bramble.api.cleanup.event.CleanupTimerStartedEvent; +import org.briarproject.bramble.api.db.DatabaseComponent; +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.db.Transaction; +import org.briarproject.bramble.api.event.Event; +import org.briarproject.bramble.api.event.EventListener; +import org.briarproject.bramble.api.lifecycle.Service; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.ClientId; +import org.briarproject.bramble.api.sync.Group; +import org.briarproject.bramble.api.sync.GroupId; +import org.briarproject.bramble.api.sync.MessageId; +import org.briarproject.bramble.api.system.Clock; +import org.briarproject.bramble.api.system.TaskScheduler; +import org.briarproject.bramble.api.versioning.ClientMajorVersion; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; +import javax.inject.Inject; + +import static java.lang.Math.max; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.api.db.DatabaseComponent.NO_CLEANUP_DEADLINE; +import static org.briarproject.bramble.util.LogUtils.logException; + +@ThreadSafe +@NotNullByDefault +class CleanupManagerImpl implements CleanupManager, Service, EventListener { + + private static final Logger LOG = + getLogger(CleanupManagerImpl.class.getName()); + + private final Executor dbExecutor; + private final DatabaseComponent db; + private final TaskScheduler taskScheduler; + private final Clock clock; + private final Map hooks = + new ConcurrentHashMap<>(); + private final Object lock = new Object(); + + @GuardedBy("lock") + private final Set pending = new HashSet<>(); + + @Inject + CleanupManagerImpl(@DatabaseExecutor Executor dbExecutor, + DatabaseComponent db, TaskScheduler taskScheduler, Clock clock) { + this.dbExecutor = dbExecutor; + this.db = db; + this.taskScheduler = taskScheduler; + this.clock = clock; + } + + @Override + public void registerCleanupHook(ClientId c, int majorVersion, + CleanupHook hook) { + hooks.put(new ClientMajorVersion(c, majorVersion), hook); + } + + @Override + public void startService() { + maybeScheduleTask(clock.currentTimeMillis()); + } + + @Override + public void stopService() { + } + + @Override + public void eventOccurred(Event e) { + if (e instanceof CleanupTimerStartedEvent) { + CleanupTimerStartedEvent a = (CleanupTimerStartedEvent) e; + maybeScheduleTask(a.getCleanupDeadline()); + } + } + + private void maybeScheduleTask(long deadline) { + synchronized (lock) { + for (CleanupTask task : pending) { + if (task.deadline <= deadline) return; + } + CleanupTask task = new CleanupTask(deadline); + pending.add(task); + scheduleTask(task); + } + } + + private void scheduleTask(CleanupTask task) { + long now = clock.currentTimeMillis(); + long delay = max(0, task.deadline - now + BATCH_DELAY_MS); + if (LOG.isLoggable(INFO)) { + LOG.info("Scheduling cleanup task in " + delay + " ms"); + } + taskScheduler.schedule(() -> deleteMessagesAndScheduleNextTask(task), + dbExecutor, delay, MILLISECONDS); + } + + private void deleteMessagesAndScheduleNextTask(CleanupTask task) { + try { + synchronized (lock) { + pending.remove(task); + } + long deadline = db.transactionWithResult(false, txn -> { + deleteMessages(txn); + return db.getNextCleanupDeadline(txn); + }); + if (deadline != NO_CLEANUP_DEADLINE) { + maybeScheduleTask(deadline); + } + } catch (DbException e) { + logException(LOG, WARNING, e); + } + } + + private void deleteMessages(Transaction txn) throws DbException { + Map> ids = db.getMessagesToDelete(txn); + for (Entry> e : ids.entrySet()) { + GroupId groupId = e.getKey(); + Collection messageIds = e.getValue(); + if (LOG.isLoggable(INFO)) { + LOG.info(messageIds.size() + " messages to delete"); + } + for (MessageId m : messageIds) db.stopCleanupTimer(txn, m); + Group group = db.getGroup(txn, groupId); + ClientMajorVersion cv = new ClientMajorVersion(group.getClientId(), + group.getMajorVersion()); + CleanupHook hook = hooks.get(cv); + if (hook == null) { + throw new IllegalStateException("No cleanup hook for " + cv); + } + hook.deleteMessages(txn, groupId, messageIds); + } + } + + private static class CleanupTask { + + private final long deadline; + + private CleanupTask(long deadline) { + this.deadline = deadline; + } + } +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/cleanup/CleanupModule.java b/bramble-core/src/main/java/org/briarproject/bramble/cleanup/CleanupModule.java new file mode 100644 index 000000000..955dd3bdc --- /dev/null +++ b/bramble-core/src/main/java/org/briarproject/bramble/cleanup/CleanupModule.java @@ -0,0 +1,29 @@ +package org.briarproject.bramble.cleanup; + +import org.briarproject.bramble.api.cleanup.CleanupManager; +import org.briarproject.bramble.api.event.EventBus; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class CleanupModule { + + public static class EagerSingletons { + @Inject + CleanupManager cleanupManager; + } + + @Provides + @Singleton + CleanupManager provideCleanupManager(LifecycleManager lifecycleManager, + EventBus eventBus, CleanupManagerImpl cleanupManager) { + lifecycleManager.registerService(cleanupManager); + eventBus.addListener(cleanupManager); + return cleanupManager; + } +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/client/ClientHelperImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/client/ClientHelperImpl.java index 604570aee..f483acdc8 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/client/ClientHelperImpl.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/client/ClientHelperImpl.java @@ -2,11 +2,13 @@ package org.briarproject.bramble.client; import org.briarproject.bramble.api.FormatException; import org.briarproject.bramble.api.client.ClientHelper; +import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.crypto.CryptoComponent; import org.briarproject.bramble.api.crypto.KeyParser; import org.briarproject.bramble.api.crypto.PrivateKey; import org.briarproject.bramble.api.crypto.PublicKey; import org.briarproject.bramble.api.data.BdfDictionary; +import org.briarproject.bramble.api.data.BdfEntry; import org.briarproject.bramble.api.data.BdfList; import org.briarproject.bramble.api.data.BdfReader; import org.briarproject.bramble.api.data.BdfReaderFactory; @@ -32,6 +34,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.security.GeneralSecurityException; +import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; @@ -39,6 +42,7 @@ import java.util.Map.Entry; import javax.annotation.concurrent.Immutable; import javax.inject.Inject; +import static org.briarproject.bramble.api.client.ContactGroupConstants.GROUP_KEY_CONTACT_ID; import static org.briarproject.bramble.api.identity.Author.FORMAT_VERSION; import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH; import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH; @@ -151,6 +155,12 @@ class ClientHelperImpl implements ClientHelper { return metadataParser.parse(metadata); } + @Override + public Collection getMessageIds(Transaction txn, GroupId g, + BdfDictionary query) throws DbException, FormatException { + return db.getMessageIds(txn, g, metadataEncoder.encode(query)); + } + @Override public BdfDictionary getMessageMetadataAsDictionary(MessageId m) throws DbException, FormatException { @@ -389,4 +399,27 @@ class ClientHelperImpl implements ClientHelper { return tpMap; } + @Override + public ContactId getContactId(Transaction txn, GroupId contactGroupId) + throws DbException { + try { + BdfDictionary meta = + getGroupMetadataAsDictionary(txn, contactGroupId); + return new ContactId(meta.getLong(GROUP_KEY_CONTACT_ID).intValue()); + } catch (FormatException e) { + throw new DbException(e); // Invalid group metadata + } + } + + @Override + public void setContactId(Transaction txn, GroupId contactGroupId, + ContactId c) throws DbException { + BdfDictionary meta = BdfDictionary.of( + new BdfEntry(GROUP_KEY_CONTACT_ID, c.getInt())); + try { + mergeGroupMetadata(txn, contactGroupId, meta); + } catch (FormatException e) { + throw new AssertionError(e); + } + } } diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java b/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java index a256d91ff..b6832d1e9 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/db/Database.java @@ -504,6 +504,25 @@ interface Database { */ Collection getMessagesToShare(T txn) throws DbException; + /** + * Returns the IDs of any messages of any messages that are due for + * deletion, along with their group IDs. + *

+ * Read-only. + */ + Map> getMessagesToDelete(T txn) + throws DbException; + + /** + * Returns the next time (in milliseconds since the Unix epoch) when a + * message is due to be deleted, or + * {@link DatabaseComponent#NO_CLEANUP_DEADLINE} if no messages are + * scheduled to be deleted. + *

+ * Read-only. + */ + long getNextCleanupDeadline(T txn) throws DbException; + /** * Returns the next time (in milliseconds since the Unix epoch) when a * message is due to be sent to the given contact. The returned value may @@ -613,8 +632,10 @@ interface Database { /** * Marks a message as having been seen by the given contact. + * + * @return True if the message was not already marked as seen. */ - void raiseSeenFlag(T txn, ContactId c, MessageId m) throws DbException; + boolean raiseSeenFlag(T txn, ContactId c, MessageId m) throws DbException; /** * Removes a contact from the database. @@ -678,6 +699,13 @@ interface Database { */ void resetExpiryTime(T txn, ContactId c, MessageId m) throws DbException; + /** + * Sets the cleanup timer duration for the given message. This does not + * start the message's cleanup timer. + */ + void setCleanupTimerDuration(T txn, MessageId m, long duration) + throws DbException; + /** * Marks the given contact as verified. */ @@ -708,9 +736,10 @@ interface Database { void setMessagePermanent(T txn, MessageId m) throws DbException; /** - * Marks the given message as shared. + * Marks the given message as shared or not. */ - void setMessageShared(T txn, MessageId m) throws DbException; + void setMessageShared(T txn, MessageId m, boolean shared) + throws DbException; /** * Sets the validation and delivery state of the given message. @@ -737,6 +766,22 @@ interface Database { void setTransportKeysActive(T txn, TransportId t, KeySetId k) throws DbException; + /** + * Starts the cleanup timer for the given message, if a timer duration + * has been set and the timer has not already been started. + * + * @return The cleanup deadline, or + * {@link DatabaseComponent#TIMER_NOT_STARTED} if no timer duration has + * been set for this message or its timer has already been started. + */ + long startCleanupTimer(T txn, MessageId m) throws DbException; + + /** + * Stops the cleanup timer for the given message, if the timer has been + * started. + */ + void stopCleanupTimer(T txn, MessageId m) throws DbException; + /** * Updates the transmission count, expiry time and estimated time of arrival * of the given message with respect to the given contact, using the latency diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/DatabaseComponentImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/db/DatabaseComponentImpl.java index 6ec1760af..9b7ac74fa 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/db/DatabaseComponentImpl.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/db/DatabaseComponentImpl.java @@ -1,5 +1,6 @@ package org.briarproject.bramble.db; +import org.briarproject.bramble.api.cleanup.event.CleanupTimerStartedEvent; import org.briarproject.bramble.api.contact.Contact; import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.contact.PendingContact; @@ -576,6 +577,15 @@ class DatabaseComponentImpl implements DatabaseComponent { return db.getMessageIds(txn, g); } + @Override + public Collection getMessageIds(Transaction transaction, + GroupId g, Metadata query) throws DbException { + T txn = unbox(transaction); + if (!db.containsGroup(txn, g)) + throw new NoSuchGroupException(); + return db.getMessageIds(txn, g, query); + } + @Override public Collection getMessagesToValidate(Transaction transaction) throws DbException { @@ -597,6 +607,13 @@ class DatabaseComponentImpl implements DatabaseComponent { return db.getMessagesToShare(txn); } + @Override + public Map> getMessagesToDelete( + Transaction transaction) throws DbException { + T txn = unbox(transaction); + return db.getMessagesToDelete(txn); + } + @Override public Map getMessageMetadata(Transaction transaction, GroupId g) throws DbException { @@ -692,6 +709,13 @@ class DatabaseComponentImpl implements DatabaseComponent { return db.getMessageDependents(txn, m); } + @Override + public long getNextCleanupDeadline(Transaction transaction) + throws DbException { + T txn = unbox(transaction); + return db.getNextCleanupDeadline(txn); + } + @Override public long getNextSendTime(Transaction transaction, ContactId c) throws DbException { @@ -795,8 +819,17 @@ class DatabaseComponentImpl implements DatabaseComponent { Collection acked = new ArrayList<>(); for (MessageId m : a.getMessageIds()) { if (db.containsVisibleMessage(txn, c, m)) { - db.raiseSeenFlag(txn, c, m); - acked.add(m); + if (db.raiseSeenFlag(txn, c, m)) { + // This is the first time the message has been acked by + // this contact. Start the cleanup timer (a no-op unless + // a cleanup deadline has been set for this message) + long deadline = db.startCleanupTimer(txn, m); + if (deadline != TIMER_NOT_STARTED) { + transaction.attach(new CleanupTimerStartedEvent(m, + deadline)); + } + acked.add(m); + } } } if (acked.size() > 0) { @@ -952,6 +985,16 @@ class DatabaseComponentImpl implements DatabaseComponent { db.removeTransportKeys(txn, t, k); } + @Override + public void setCleanupTimerDuration(Transaction transaction, MessageId m, + long duration) throws DbException { + if (transaction.isReadOnly()) throw new IllegalArgumentException(); + T txn = unbox(transaction); + if (!db.containsMessage(txn, m)) + throw new NoSuchMessageException(); + db.setCleanupTimerDuration(txn, m, duration); + } + @Override public void setContactVerified(Transaction transaction, ContactId c) throws DbException { @@ -1001,6 +1044,16 @@ class DatabaseComponentImpl implements DatabaseComponent { db.setMessagePermanent(txn, m); } + @Override + public void setMessageNotShared(Transaction transaction, MessageId m) + throws DbException { + if (transaction.isReadOnly()) throw new IllegalArgumentException(); + T txn = unbox(transaction); + if (!db.containsMessage(txn, m)) + throw new NoSuchMessageException(); + db.setMessageShared(txn, m, false); + } + @Override public void setMessageShared(Transaction transaction, MessageId m) throws DbException { @@ -1010,7 +1063,7 @@ class DatabaseComponentImpl implements DatabaseComponent { throw new NoSuchMessageException(); if (db.getMessageState(txn, m) != DELIVERED) throw new IllegalArgumentException("Shared undelivered message"); - db.setMessageShared(txn, m); + db.setMessageShared(txn, m, true); transaction.attach(new MessageSharedEvent(m)); } @@ -1082,6 +1135,30 @@ class DatabaseComponentImpl implements DatabaseComponent { db.setTransportKeysActive(txn, t, k); } + @Override + public long startCleanupTimer(Transaction transaction, MessageId m) + throws DbException { + if (transaction.isReadOnly()) throw new IllegalArgumentException(); + T txn = unbox(transaction); + if (!db.containsMessage(txn, m)) + throw new NoSuchMessageException(); + long deadline = db.startCleanupTimer(txn, m); + if (deadline != TIMER_NOT_STARTED) { + transaction.attach(new CleanupTimerStartedEvent(m, deadline)); + } + return deadline; + } + + @Override + public void stopCleanupTimer(Transaction transaction, MessageId m) + throws DbException { + if (transaction.isReadOnly()) throw new IllegalArgumentException(); + T txn = unbox(transaction); + if (!db.containsMessage(txn, m)) + throw new NoSuchMessageException(); + db.stopCleanupTimer(txn, m); + } + @Override public void updateTransportKeys(Transaction transaction, Collection keys) throws DbException { diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java index b559b2924..72ceec594 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/db/JdbcDatabase.java @@ -72,6 +72,8 @@ import static java.util.Arrays.asList; import static java.util.logging.Level.INFO; import static java.util.logging.Level.WARNING; import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.api.db.DatabaseComponent.NO_CLEANUP_DEADLINE; +import static org.briarproject.bramble.api.db.DatabaseComponent.TIMER_NOT_STARTED; import static org.briarproject.bramble.api.db.Metadata.REMOVE; import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE; import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED; @@ -99,7 +101,7 @@ import static org.briarproject.bramble.util.LogUtils.now; abstract class JdbcDatabase implements Database { // Package access for testing - static final int CODE_SCHEMA_VERSION = 47; + static final int CODE_SCHEMA_VERSION = 48; // Time period offsets for incoming transport keys private static final int OFFSET_PREV = -1; @@ -181,6 +183,11 @@ abstract class JdbcDatabase implements Database { + " state INT NOT NULL," + " shared BOOLEAN NOT NULL," + " temporary BOOLEAN NOT NULL," + // Null if no timer duration has been set + + " cleanupTimerDuration BIGINT," + // Null if no timer duration has been set or the timer + // hasn't started + + " cleanupDeadline BIGINT," + " length INT NOT NULL," + " raw BLOB," // Null if message has been deleted + " PRIMARY KEY (messageId)," @@ -337,6 +344,10 @@ abstract class JdbcDatabase implements Database { "CREATE INDEX IF NOT EXISTS statusesByContactIdTimestamp" + " ON statuses (contactId, timestamp)"; + private static final String INDEX_MESSAGES_BY_CLEANUP_DEADLINE = + "CREATE INDEX IF NOT EXISTS messagesByCleanupDeadline" + + " ON messages (cleanupDeadline)"; + private static final Logger LOG = getLogger(JdbcDatabase.class.getName()); @@ -480,7 +491,8 @@ abstract class JdbcDatabase implements Database { new Migration43_44(dbTypes), new Migration44_45(), new Migration45_46(), - new Migration46_47(dbTypes) + new Migration46_47(dbTypes), + new Migration47_48() ); } @@ -558,6 +570,7 @@ abstract class JdbcDatabase implements Database { s.executeUpdate(INDEX_MESSAGE_DEPENDENCIES_BY_DEPENDENCY_ID); s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_GROUP_ID); s.executeUpdate(INDEX_STATUSES_BY_CONTACT_ID_TIMESTAMP); + s.executeUpdate(INDEX_MESSAGES_BY_CLEANUP_DEADLINE); s.close(); } catch (SQLException e) { tryToClose(s, LOG, WARNING); @@ -1317,7 +1330,9 @@ abstract class JdbcDatabase implements Database { public void deleteMessage(Connection txn, MessageId m) throws DbException { PreparedStatement ps = null; try { - String sql = "UPDATE messages SET raw = NULL WHERE messageId = ?"; + String sql = "UPDATE messages" + + " SET raw = NULL, cleanupDeadline = NULL" + + " WHERE messageId = ?"; ps = txn.prepareStatement(sql); ps.setBytes(1, m.getBytes()); int affected = ps.executeUpdate(); @@ -1796,7 +1811,6 @@ abstract class JdbcDatabase implements Database { // Return early if there are no matches if (intersection.isEmpty()) return Collections.emptySet(); } - if (intersection == null) throw new AssertionError(); return intersection; } catch (SQLException e) { tryToClose(rs, LOG, WARNING); @@ -2253,6 +2267,39 @@ abstract class JdbcDatabase implements Database { } } + @Override + public Map> getMessagesToDelete( + Connection txn) throws DbException { + long now = clock.currentTimeMillis(); + PreparedStatement ps = null; + ResultSet rs = null; + try { + String sql = "SELECT messageId, groupId FROM messages" + + " WHERE cleanupDeadline <= ?"; + ps = txn.prepareStatement(sql); + ps.setLong(1, now); + rs = ps.executeQuery(); + Map> ids = new HashMap<>(); + while (rs.next()) { + MessageId m = new MessageId(rs.getBytes(1)); + GroupId g = new GroupId(rs.getBytes(2)); + Collection messageIds = ids.get(g); + if (messageIds == null) { + messageIds = new ArrayList<>(); + ids.put(g, messageIds); + } + messageIds.add(m); + } + rs.close(); + ps.close(); + return ids; + } catch (SQLException e) { + tryToClose(rs, LOG, WARNING); + tryToClose(ps, LOG, WARNING); + throw new DbException(e); + } + } + @Override public long getNextSendTime(Connection txn, ContactId c) throws DbException { @@ -2283,6 +2330,31 @@ abstract class JdbcDatabase implements Database { } } + @Override + public long getNextCleanupDeadline(Connection txn) throws DbException { + Statement s = null; + ResultSet rs = null; + try { + String sql = "SELECT cleanupDeadline FROM messages" + + " WHERE cleanupDeadline IS NOT NULL" + + " ORDER BY cleanupDeadline LIMIT 1"; + s = txn.createStatement(); + rs = s.executeQuery(sql); + long nextDeadline = NO_CLEANUP_DEADLINE; + if (rs.next()) { + nextDeadline = rs.getLong(1); + if (rs.next()) throw new AssertionError(); + } + rs.close(); + s.close(); + return nextDeadline; + } catch (SQLException e) { + tryToClose(rs, LOG, WARNING); + tryToClose(s, LOG, WARNING); + throw new DbException(e); + } + } + @Override public PendingContact getPendingContact(Connection txn, PendingContactId p) throws DbException { @@ -2803,7 +2875,7 @@ abstract class JdbcDatabase implements Database { } @Override - public void raiseSeenFlag(Connection txn, ContactId c, MessageId m) + public boolean raiseSeenFlag(Connection txn, ContactId c, MessageId m) throws DbException { PreparedStatement ps = null; try { @@ -2815,6 +2887,7 @@ abstract class JdbcDatabase implements Database { int affected = ps.executeUpdate(); if (affected < 0 || affected > 1) throw new DbStateException(); ps.close(); + return affected == 1; } catch (SQLException e) { tryToClose(ps, LOG, WARNING); throw new DbException(e); @@ -3048,6 +3121,25 @@ abstract class JdbcDatabase implements Database { } } + @Override + public void setCleanupTimerDuration(Connection txn, MessageId m, + long duration) throws DbException { + PreparedStatement ps = null; + try { + String sql = "UPDATE messages SET cleanupTimerDuration = ?" + + " WHERE messageId = ? AND cleanupTimerDuration IS NULL"; + ps = txn.prepareStatement(sql); + ps.setLong(1, duration); + ps.setBytes(2, m.getBytes()); + int affected = ps.executeUpdate(); + if (affected < 0 || affected > 1) throw new DbStateException(); + ps.close(); + } catch (SQLException e) { + tryToClose(ps, LOG, WARNING); + throw new DbException(e); + } + } + @Override public void setContactVerified(Connection txn, ContactId c) throws DbException { @@ -3155,22 +3247,24 @@ abstract class JdbcDatabase implements Database { } @Override - public void setMessageShared(Connection txn, MessageId m) + public void setMessageShared(Connection txn, MessageId m, boolean shared) throws DbException { PreparedStatement ps = null; try { - String sql = "UPDATE messages SET shared = TRUE" + String sql = "UPDATE messages SET shared = ?" + " WHERE messageId = ?"; ps = txn.prepareStatement(sql); - ps.setBytes(1, m.getBytes()); + ps.setBoolean(1, shared); + ps.setBytes(2, m.getBytes()); int affected = ps.executeUpdate(); if (affected < 0 || affected > 1) throw new DbStateException(); ps.close(); // Update denormalised column in statuses - sql = "UPDATE statuses SET messageShared = TRUE" + sql = "UPDATE statuses SET messageShared = ?" + " WHERE messageId = ?"; ps = txn.prepareStatement(sql); - ps.setBytes(1, m.getBytes()); + ps.setBoolean(1, shared); + ps.setBytes(2, m.getBytes()); affected = ps.executeUpdate(); if (affected < 0) throw new DbStateException(); ps.close(); @@ -3299,6 +3393,60 @@ abstract class JdbcDatabase implements Database { } } + @Override + public long startCleanupTimer(Connection txn, MessageId m) + throws DbException { + long now = clock.currentTimeMillis(); + PreparedStatement ps = null; + ResultSet rs = null; + try { + String sql = "UPDATE messages" + + " SET cleanupDeadline = ? + cleanupTimerDuration" + + " WHERE messageId = ?" + + " AND cleanupTimerDuration IS NOT NULL" + + " AND cleanupDeadline IS NULL"; + ps = txn.prepareStatement(sql); + ps.setLong(1, now); + ps.setBytes(2, m.getBytes()); + int affected = ps.executeUpdate(); + if (affected < 0 || affected > 1) throw new DbStateException(); + ps.close(); + if (affected == 0) return TIMER_NOT_STARTED; + sql = "SELECT cleanupDeadline FROM messages WHERE messageId = ?"; + ps = txn.prepareStatement(sql); + ps.setBytes(1, m.getBytes()); + rs = ps.executeQuery(); + if (!rs.next()) throw new DbStateException(); + long deadline = rs.getLong(1); + if (rs.next()) throw new DbStateException(); + rs.close(); + ps.close(); + return deadline; + } catch (SQLException e) { + tryToClose(rs, LOG, WARNING); + tryToClose(ps, LOG, WARNING); + throw new DbException(e); + } + } + + @Override + public void stopCleanupTimer(Connection txn, MessageId m) + throws DbException { + PreparedStatement ps = null; + try { + String sql = "UPDATE messages SET cleanupDeadline = NULL" + + " WHERE messageId = ?"; + ps = txn.prepareStatement(sql); + ps.setBytes(1, m.getBytes()); + int affected = ps.executeUpdate(); + if (affected < 0 || affected > 1) throw new DbStateException(); + ps.close(); + } catch (SQLException e) { + tryToClose(ps, LOG, WARNING); + throw new DbException(e); + } + } + @Override public void updateExpiryTimeAndEta(Connection txn, ContactId c, MessageId m, int maxLatency) throws DbException { diff --git a/bramble-core/src/main/java/org/briarproject/bramble/db/Migration47_48.java b/bramble-core/src/main/java/org/briarproject/bramble/db/Migration47_48.java new file mode 100644 index 000000000..690185794 --- /dev/null +++ b/bramble-core/src/main/java/org/briarproject/bramble/db/Migration47_48.java @@ -0,0 +1,47 @@ +package org.briarproject.bramble.db; + +import org.briarproject.bramble.api.db.DbException; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.logging.Logger; + +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.db.JdbcUtils.tryToClose; + +class Migration47_48 implements Migration { + + private static final Logger LOG = getLogger(Migration47_48.class.getName()); + + @Override + public int getStartVersion() { + return 47; + } + + @Override + public int getEndVersion() { + return 48; + } + + @Override + public void migrate(Connection txn) throws DbException { + Statement s = null; + try { + s = txn.createStatement(); + // Null if no timer duration has been set + s.execute("ALTER TABLE messages" + + " ADD COLUMN cleanupTimerDuration BIGINT"); + // Null if no timer duration has been set or the timer + // hasn't started + s.execute("ALTER TABLE messages" + + " ADD COLUMN cleanupDeadline BIGINT"); + s.execute("CREATE INDEX IF NOT EXISTS messagesByCleanupDeadline" + + " ON messages (cleanupDeadline)"); + } catch (SQLException e) { + tryToClose(s, LOG, WARNING); + throw new DbException(e); + } + } +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningConstants.java b/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningConstants.java index e68ecc468..d3822a23c 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningConstants.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningConstants.java @@ -5,6 +5,5 @@ interface ClientVersioningConstants { // Metadata keys String MSG_KEY_UPDATE_VERSION = "version"; String MSG_KEY_LOCAL = "local"; - String GROUP_KEY_CONTACT_ID = "contactId"; } diff --git a/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningManagerImpl.java index b334f3bea..6aadf889a 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningManagerImpl.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/versioning/ClientVersioningManagerImpl.java @@ -50,7 +50,6 @@ import static java.util.Collections.emptyList; import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE; import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED; import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE; -import static org.briarproject.bramble.versioning.ClientVersioningConstants.GROUP_KEY_CONTACT_ID; import static org.briarproject.bramble.versioning.ClientVersioningConstants.MSG_KEY_LOCAL; import static org.briarproject.bramble.versioning.ClientVersioningConstants.MSG_KEY_UPDATE_VERSION; @@ -161,13 +160,7 @@ class ClientVersioningManagerImpl implements ClientVersioningManager, db.addGroup(txn, g); db.setGroupVisibility(txn, c.getId(), g.getId(), SHARED); // Attach the contact ID to the group - BdfDictionary meta = new BdfDictionary(); - meta.put(GROUP_KEY_CONTACT_ID, c.getId().getInt()); - try { - clientHelper.mergeGroupMetadata(txn, g.getId(), meta); - } catch (FormatException e) { - throw new AssertionError(e); - } + clientHelper.setContactId(txn, g.getId(), c.getId()); // Create and store the first local update List versions = new ArrayList<>(clients); Collections.sort(versions); @@ -229,7 +222,7 @@ class ClientVersioningManagerImpl implements ClientVersioningManager, Map after = getVisibilities(newLocalStates, newRemoteStates); // Call hooks for any visibilities that have changed - ContactId c = getContactId(txn, m.getGroupId()); + ContactId c = clientHelper.getContactId(txn, m.getGroupId()); if (!before.equals(after)) { Contact contact = db.getContact(txn, c); callVisibilityHooks(txn, contact, before, after); @@ -521,17 +514,6 @@ class ClientVersioningManagerImpl implements ClientVersioningManager, storeUpdate(txn, g, states, 1); } - private ContactId getContactId(Transaction txn, GroupId g) - throws DbException { - try { - BdfDictionary meta = - clientHelper.getGroupMetadataAsDictionary(txn, g); - return new ContactId(meta.getLong(GROUP_KEY_CONTACT_ID).intValue()); - } catch (FormatException e) { - throw new DbException(e); - } - } - private List updateStatesFromRemoteStates( List oldLocalStates, List remoteStates) { Set remoteSet = new HashSet<>(); diff --git a/bramble-core/src/test/java/org/briarproject/bramble/BrambleCoreIntegrationTestEagerSingletons.java b/bramble-core/src/test/java/org/briarproject/bramble/BrambleCoreIntegrationTestEagerSingletons.java index 679bf0719..0916b217b 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/BrambleCoreIntegrationTestEagerSingletons.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/BrambleCoreIntegrationTestEagerSingletons.java @@ -1,18 +1,18 @@ package org.briarproject.bramble; -import org.briarproject.bramble.system.DefaultTaskSchedulerModule; +import org.briarproject.bramble.system.TimeTravelModule; public interface BrambleCoreIntegrationTestEagerSingletons extends BrambleCoreEagerSingletons { - void inject(DefaultTaskSchedulerModule.EagerSingletons init); + void inject(TimeTravelModule.EagerSingletons init); class Helper { public static void injectEagerSingletons( BrambleCoreIntegrationTestEagerSingletons c) { BrambleCoreEagerSingletons.Helper.injectEagerSingletons(c); - c.inject(new DefaultTaskSchedulerModule.EagerSingletons()); + c.inject(new TimeTravelModule.EagerSingletons()); } } } diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/DatabaseComponentImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/DatabaseComponentImplTest.java index b8fb04bc8..39615b2e5 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/db/DatabaseComponentImplTest.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/db/DatabaseComponentImplTest.java @@ -1,5 +1,6 @@ package org.briarproject.bramble.db; +import org.briarproject.bramble.api.cleanup.event.CleanupTimerStartedEvent; import org.briarproject.bramble.api.contact.Contact; import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.contact.PendingContactId; @@ -69,6 +70,8 @@ import static java.util.Arrays.asList; import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.HOURS; +import static org.briarproject.bramble.api.db.DatabaseComponent.TIMER_NOT_STARTED; import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE; import static org.briarproject.bramble.api.sync.Group.Visibility.SHARED; import static org.briarproject.bramble.api.sync.Group.Visibility.VISIBLE; @@ -510,11 +513,11 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { throws Exception { context.checking(new Expectations() {{ // Check whether the group is in the DB (which it's not) - exactly(8).of(database).startTransaction(); + exactly(10).of(database).startTransaction(); will(returnValue(txn)); - exactly(8).of(database).containsGroup(txn, groupId); + exactly(10).of(database).containsGroup(txn, groupId); will(returnValue(false)); - exactly(8).of(database).abortTransaction(txn); + exactly(10).of(database).abortTransaction(txn); // Allow other checks to pass allowing(database).containsContact(txn, contactId); will(returnValue(true)); @@ -523,7 +526,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { eventExecutor, shutdownManager); try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getGroup(transaction, groupId)); fail(); } catch (NoSuchGroupException expected) { @@ -531,7 +534,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getGroupMetadata(transaction, groupId)); fail(); } catch (NoSuchGroupException expected) { @@ -539,7 +542,23 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> + db.getMessageIds(transaction, groupId)); + fail(); + } catch (NoSuchGroupException expected) { + // Expected + } + + try { + db.transaction(true, transaction -> + db.getMessageIds(transaction, groupId, new Metadata())); + fail(); + } catch (NoSuchGroupException expected) { + // Expected + } + + try { + db.transaction(true, transaction -> db.getMessageMetadata(transaction, groupId)); fail(); } catch (NoSuchGroupException expected) { @@ -547,7 +566,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageMetadata(transaction, groupId, new Metadata())); fail(); @@ -556,7 +575,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageStatus(transaction, contactId, groupId)); fail(); } catch (NoSuchGroupException expected) { @@ -594,11 +613,11 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { throws Exception { context.checking(new Expectations() {{ // Check whether the message is in the DB (which it's not) - exactly(12).of(database).startTransaction(); + exactly(15).of(database).startTransaction(); will(returnValue(txn)); - exactly(12).of(database).containsMessage(txn, messageId); + exactly(15).of(database).containsMessage(txn, messageId); will(returnValue(false)); - exactly(12).of(database).abortTransaction(txn); + exactly(15).of(database).abortTransaction(txn); // Allow other checks to pass allowing(database).containsContact(txn, contactId); will(returnValue(true)); @@ -623,7 +642,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessage(transaction, messageId)); fail(); } catch (NoSuchMessageException expected) { @@ -631,7 +650,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageMetadata(transaction, messageId)); fail(); } catch (NoSuchMessageException expected) { @@ -639,7 +658,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageState(transaction, messageId)); fail(); } catch (NoSuchMessageException expected) { @@ -647,7 +666,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageStatus(transaction, contactId, messageId)); fail(); } catch (NoSuchMessageException expected) { @@ -662,6 +681,15 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { // Expected } + try { + db.transaction(false, transaction -> + db.setCleanupTimerDuration(transaction, message.getId(), + HOURS.toMillis(1))); + fail(); + } catch (NoSuchMessageException expected) { + // Expected + } + try { db.transaction(false, transaction -> db.setMessagePermanent(transaction, message.getId())); @@ -687,7 +715,7 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageDependencies(transaction, messageId)); fail(); } catch (NoSuchMessageException expected) { @@ -695,12 +723,28 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { } try { - db.transaction(false, transaction -> + db.transaction(true, transaction -> db.getMessageDependents(transaction, messageId)); fail(); } catch (NoSuchMessageException expected) { // Expected } + + try { + db.transaction(false, transaction -> + db.startCleanupTimer(transaction, messageId)); + fail(); + } catch (NoSuchMessageException expected) { + // Expected + } + + try { + db.transaction(false, transaction -> + db.stopCleanupTimer(transaction, messageId)); + fail(); + } catch (NoSuchMessageException expected) { + // Expected + } } @Test @@ -981,6 +1025,9 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { oneOf(database).containsVisibleMessage(txn, contactId, messageId); will(returnValue(true)); oneOf(database).raiseSeenFlag(txn, contactId, messageId); + will(returnValue(true)); + oneOf(database).startCleanupTimer(txn, messageId); + will(returnValue(TIMER_NOT_STARTED)); // No cleanup duration was set oneOf(database).commitTransaction(txn); oneOf(eventBus).broadcast(with(any(MessagesAckedEvent.class))); }}); @@ -993,6 +1040,56 @@ public class DatabaseComponentImplTest extends BrambleMockTestCase { }); } + @Test + public void testReceiveDuplicateAck() throws Exception { + context.checking(new Expectations() {{ + oneOf(database).startTransaction(); + will(returnValue(txn)); + oneOf(database).containsContact(txn, contactId); + will(returnValue(true)); + oneOf(database).containsVisibleMessage(txn, contactId, messageId); + will(returnValue(true)); + oneOf(database).raiseSeenFlag(txn, contactId, messageId); + will(returnValue(false)); // Already acked + oneOf(database).commitTransaction(txn); + }}); + DatabaseComponent db = createDatabaseComponent(database, eventBus, + eventExecutor, shutdownManager); + + db.transaction(false, transaction -> { + Ack a = new Ack(singletonList(messageId)); + db.receiveAck(transaction, contactId, a); + }); + } + + @Test + public void testReceiveAckWithCleanupTimer() throws Exception { + long deadline = System.currentTimeMillis(); + context.checking(new Expectations() {{ + oneOf(database).startTransaction(); + will(returnValue(txn)); + oneOf(database).containsContact(txn, contactId); + will(returnValue(true)); + oneOf(database).containsVisibleMessage(txn, contactId, messageId); + will(returnValue(true)); + oneOf(database).raiseSeenFlag(txn, contactId, messageId); + will(returnValue(true)); + oneOf(database).startCleanupTimer(txn, messageId); + will(returnValue(deadline)); + oneOf(database).commitTransaction(txn); + oneOf(eventBus).broadcast(with(any( + CleanupTimerStartedEvent.class))); + oneOf(eventBus).broadcast(with(any(MessagesAckedEvent.class))); + }}); + DatabaseComponent db = createDatabaseComponent(database, eventBus, + eventExecutor, shutdownManager); + + db.transaction(false, transaction -> { + Ack a = new Ack(singletonList(messageId)); + db.receiveAck(transaction, contactId, a); + }); + } + @Test public void testReceiveMessage() throws Exception { context.checking(new Expectations() {{ diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabaseTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabaseTest.java index 40d9b7059..4dfec5fce 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabaseTest.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabaseTest.java @@ -57,10 +57,11 @@ import java.util.concurrent.atomic.AtomicLong; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.concurrent.TimeUnit.SECONDS; +import static org.briarproject.bramble.api.db.DatabaseComponent.NO_CLEANUP_DEADLINE; +import static org.briarproject.bramble.api.db.DatabaseComponent.TIMER_NOT_STARTED; import static org.briarproject.bramble.api.db.Metadata.REMOVE; import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH; import static org.briarproject.bramble.api.sync.Group.Visibility.INVISIBLE; @@ -355,7 +356,7 @@ public abstract class JdbcDatabaseTest extends BrambleTestCase { assertTrue(ids.isEmpty()); // Sharing the message should make it sendable - db.setMessageShared(txn, messageId); + db.setMessageShared(txn, messageId, true); ids = db.getMessagesToSend(txn, contactId, ONE_MEGABYTE, MAX_LATENCY); assertEquals(singletonList(messageId), ids); ids = db.getMessagesToOffer(txn, contactId, 100, MAX_LATENCY); @@ -635,8 +636,7 @@ public abstract class JdbcDatabaseTest extends BrambleTestCase { // The group should not be visible to the contact assertEquals(INVISIBLE, db.getGroupVisibility(txn, contactId, groupId)); - assertEquals(emptyMap(), - db.getGroupVisibility(txn, groupId)); + assertTrue(db.getGroupVisibility(txn, groupId).isEmpty()); // Make the group visible to the contact db.addGroupVisibility(txn, contactId, groupId, false); @@ -659,8 +659,7 @@ public abstract class JdbcDatabaseTest extends BrambleTestCase { // Make the group invisible again db.removeGroupVisibility(txn, contactId, groupId); assertEquals(INVISIBLE, db.getGroupVisibility(txn, contactId, groupId)); - assertEquals(emptyMap(), - db.getGroupVisibility(txn, groupId)); + assertTrue(db.getGroupVisibility(txn, groupId).isEmpty()); db.commitTransaction(txn); db.close(); @@ -2044,7 +2043,7 @@ public abstract class JdbcDatabaseTest extends BrambleTestCase { assertEquals(Long.MAX_VALUE, db.getNextSendTime(txn, contactId)); // Share the message - now it should be sendable immediately - db.setMessageShared(txn, messageId); + db.setMessageShared(txn, messageId, true); assertEquals(0, db.getNextSendTime(txn, contactId)); // Mark the message as requested - it should still be sendable @@ -2412,6 +2411,87 @@ public abstract class JdbcDatabaseTest extends BrambleTestCase { assertFalse(db.wasDirtyOnInitialisation()); } + @Test + public void testCleanupTimer() throws Exception { + long duration = 60_000; + long now = System.currentTimeMillis(); + AtomicLong time = new AtomicLong(now); + Database db = + open(false, new TestMessageFactory(), new SettableClock(time)); + Connection txn = db.startTransaction(); + + // No messages should be due or scheduled for deletion + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(NO_CLEANUP_DEADLINE, db.getNextCleanupDeadline(txn)); + + // Add a group and a message + db.addGroup(txn, group); + db.addMessage(txn, message, DELIVERED, false, false, null); + + // No messages should be due or scheduled for deletion + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(NO_CLEANUP_DEADLINE, db.getNextCleanupDeadline(txn)); + + // Set the message's cleanup timer duration + db.setCleanupTimerDuration(txn, messageId, duration); + + // No messages should be due or scheduled for deletion + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(NO_CLEANUP_DEADLINE, db.getNextCleanupDeadline(txn)); + + // Start the message's cleanup timer + assertEquals(now + duration, db.startCleanupTimer(txn, messageId)); + + // The timer can't be started again + assertEquals(TIMER_NOT_STARTED, db.startCleanupTimer(txn, messageId)); + + // No messages should be due for deletion, but the message should be + // scheduled for deletion + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(now + duration, db.getNextCleanupDeadline(txn)); + + // Stop the timer + db.stopCleanupTimer(txn, messageId); + + // No messages should be due or scheduled for deletion + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(NO_CLEANUP_DEADLINE, db.getNextCleanupDeadline(txn)); + + // Start the timer again + assertEquals(now + duration, db.startCleanupTimer(txn, messageId)); + + // No messages should be due for deletion, but the message should be + // scheduled for deletion + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(now + duration, db.getNextCleanupDeadline(txn)); + + // 1 ms before the timer expires, no messages should be due for + // deletion but the message should be scheduled for deletion + time.set(now + duration - 1); + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(now + duration, db.getNextCleanupDeadline(txn)); + + // When the timer expires, the message should be due and scheduled for + // deletion + time.set(now + duration); + assertEquals(singletonMap(groupId, singletonList(messageId)), + db.getMessagesToDelete(txn)); + assertEquals(now + duration, db.getNextCleanupDeadline(txn)); + + // 1 ms after the timer expires, the message should be due and + // scheduled for deletion + time.set(now + duration + 1); + assertEquals(singletonMap(groupId, singletonList(messageId)), + db.getMessagesToDelete(txn)); + assertEquals(now + duration, db.getNextCleanupDeadline(txn)); + + // Once the message has been deleted, it should no longer be due + // or scheduled for deletion + db.deleteMessage(txn, messageId); + assertTrue(db.getMessagesToDelete(txn).isEmpty()); + assertEquals(NO_CLEANUP_DEADLINE, db.getNextCleanupDeadline(txn)); + } + private Database open(boolean resume) throws Exception { return open(resume, new TestMessageFactory(), new SystemClock()); } diff --git a/bramble-core/src/test/java/org/briarproject/bramble/system/TestTaskScheduler.java b/bramble-core/src/test/java/org/briarproject/bramble/system/TestTaskScheduler.java new file mode 100644 index 000000000..55d753e22 --- /dev/null +++ b/bramble-core/src/test/java/org/briarproject/bramble/system/TestTaskScheduler.java @@ -0,0 +1,122 @@ +package org.briarproject.bramble.system; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.system.Clock; +import org.briarproject.bramble.api.system.TaskScheduler; + +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.junit.Assert.fail; + +/** + * A {@link TaskScheduler} for use in tests. The scheduler keeps all scheduled + * tasks in a queue until {@link #runTasks()} is called. + */ +@NotNullByDefault +class TestTaskScheduler implements TaskScheduler { + + private final Queue queue = new PriorityBlockingQueue<>(); + private final Clock clock; + + TestTaskScheduler(Clock clock) { + this.clock = clock; + } + + @Override + public Cancellable schedule(Runnable task, Executor executor, long delay, + TimeUnit unit) { + AtomicBoolean cancelled = new AtomicBoolean(false); + return schedule(task, executor, delay, unit, cancelled); + } + + @Override + public Cancellable scheduleWithFixedDelay(Runnable task, Executor executor, + long delay, long interval, TimeUnit unit) { + AtomicBoolean cancelled = new AtomicBoolean(false); + return scheduleWithFixedDelay(task, executor, delay, interval, unit, + cancelled); + } + + private Cancellable schedule(Runnable task, Executor executor, long delay, + TimeUnit unit, AtomicBoolean cancelled) { + long delayMillis = MILLISECONDS.convert(delay, unit); + long dueMillis = clock.currentTimeMillis() + delayMillis; + Task t = new Task(task, executor, dueMillis, cancelled); + queue.add(t); + return t; + } + + private Cancellable scheduleWithFixedDelay(Runnable task, Executor executor, + long delay, long interval, TimeUnit unit, AtomicBoolean cancelled) { + // All executions of this periodic task share a cancelled flag + Runnable wrapped = () -> { + task.run(); + scheduleWithFixedDelay(task, executor, interval, interval, unit, + cancelled); + }; + return schedule(wrapped, executor, delay, unit, cancelled); + } + + /** + * Runs any scheduled tasks that are due. + */ + void runTasks() throws InterruptedException { + long now = clock.currentTimeMillis(); + while (true) { + Task t = queue.peek(); + if (t == null || t.dueMillis > now) return; + t = queue.poll(); + // Submit the task to its executor and wait for it to finish + if (!t.run().await(1, MINUTES)) fail(); + } + } + + private static class Task + implements Cancellable, Comparable { + + private final Runnable task; + private final Executor executor; + private final long dueMillis; + private final AtomicBoolean cancelled; + + private Task(Runnable task, Executor executor, long dueMillis, + AtomicBoolean cancelled) { + this.task = task; + this.executor = executor; + this.dueMillis = dueMillis; + this.cancelled = cancelled; + } + + @SuppressWarnings("UseCompareMethod") // Animal Sniffer + @Override + public int compareTo(Task task) { + return Long.valueOf(dueMillis).compareTo(task.dueMillis); + } + + /** + * Submits the task to its executor and returns a latch that will be + * released when the task finishes. + */ + public CountDownLatch run() { + if (cancelled.get()) return new CountDownLatch(0); + CountDownLatch latch = new CountDownLatch(1); + executor.execute(() -> { + task.run(); + latch.countDown(); + }); + return latch; + } + + @Override + public void cancel() { + cancelled.set(true); + } + } +} diff --git a/bramble-core/src/test/java/org/briarproject/bramble/system/TimeTravelModule.java b/bramble-core/src/test/java/org/briarproject/bramble/system/TimeTravelModule.java new file mode 100644 index 000000000..d00d81300 --- /dev/null +++ b/bramble-core/src/test/java/org/briarproject/bramble/system/TimeTravelModule.java @@ -0,0 +1,98 @@ +package org.briarproject.bramble.system; + +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.system.Clock; +import org.briarproject.bramble.api.system.TaskScheduler; +import org.briarproject.bramble.test.SettableClock; +import org.briarproject.bramble.test.TimeTravel; + +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicLong; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class TimeTravelModule { + + public static class EagerSingletons { + @Inject + TaskScheduler scheduler; + } + + private final ScheduledExecutorService scheduledExecutorService; + private final Clock clock; + private final TaskScheduler taskScheduler; + private final TimeTravel timeTravel; + + public TimeTravelModule() { + this(false); + } + + public TimeTravelModule(boolean travel) { + // Discard tasks that are submitted during shutdown + RejectedExecutionHandler policy = + new ScheduledThreadPoolExecutor.DiscardPolicy(); + scheduledExecutorService = + new ScheduledThreadPoolExecutor(1, policy); + if (travel) { + // Use a SettableClock and TestTaskScheduler to allow time travel + AtomicLong time = new AtomicLong(System.currentTimeMillis()); + clock = new SettableClock(time); + TestTaskScheduler testTaskScheduler = new TestTaskScheduler(clock); + taskScheduler = testTaskScheduler; + timeTravel = new TimeTravel() { + @Override + public void setCurrentTimeMillis(long now) + throws InterruptedException { + time.set(now); + testTaskScheduler.runTasks(); + } + + @Override + public void addCurrentTimeMillis(long add) + throws InterruptedException { + time.addAndGet(add); + testTaskScheduler.runTasks(); + } + }; + } else { + // Use the default clock and task scheduler + clock = new SystemClock(); + taskScheduler = new TaskSchedulerImpl(scheduledExecutorService); + timeTravel = new TimeTravel() { + @Override + public void setCurrentTimeMillis(long now) { + throw new UnsupportedOperationException(); + } + + @Override + public void addCurrentTimeMillis(long add) { + throw new UnsupportedOperationException(); + } + }; + } + } + + @Provides + Clock provideClock() { + return clock; + } + + @Provides + @Singleton + TaskScheduler provideTaskScheduler(LifecycleManager lifecycleManager) { + lifecycleManager.registerForShutdown(scheduledExecutorService); + return taskScheduler; + } + + @Provides + TimeTravel provideTimeTravel() { + return timeTravel; + } +} diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java b/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java index 4ac540199..661df400a 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java @@ -3,8 +3,8 @@ package org.briarproject.bramble.test; import org.briarproject.bramble.api.FeatureFlags; import org.briarproject.bramble.battery.DefaultBatteryManagerModule; import org.briarproject.bramble.event.DefaultEventExecutorModule; -import org.briarproject.bramble.system.DefaultTaskSchedulerModule; import org.briarproject.bramble.system.DefaultWakefulIoExecutorModule; +import org.briarproject.bramble.system.TimeTravelModule; import dagger.Module; import dagger.Provides; @@ -12,11 +12,11 @@ import dagger.Provides; @Module(includes = { DefaultBatteryManagerModule.class, DefaultEventExecutorModule.class, - DefaultTaskSchedulerModule.class, DefaultWakefulIoExecutorModule.class, TestDatabaseConfigModule.class, TestPluginConfigModule.class, - TestSecureRandomModule.class + TestSecureRandomModule.class, + TimeTravelModule.class }) public class BrambleCoreIntegrationTestModule { @@ -33,6 +33,11 @@ public class BrambleCoreIntegrationTestModule { public boolean shouldEnableProfilePictures() { return true; } + + @Override + public boolean shouldEnableDisappearingMessages() { + return true; + } }; } } diff --git a/bramble-core/src/test/java/org/briarproject/bramble/versioning/ClientVersioningManagerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/versioning/ClientVersioningManagerImplTest.java index 93cf97dcc..17d23087b 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/versioning/ClientVersioningManagerImplTest.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/versioning/ClientVersioningManagerImplTest.java @@ -38,7 +38,6 @@ import static org.briarproject.bramble.test.TestUtils.getContact; import static org.briarproject.bramble.test.TestUtils.getGroup; import static org.briarproject.bramble.test.TestUtils.getMessage; import static org.briarproject.bramble.test.TestUtils.getRandomId; -import static org.briarproject.bramble.versioning.ClientVersioningConstants.GROUP_KEY_CONTACT_ID; import static org.briarproject.bramble.versioning.ClientVersioningConstants.MSG_KEY_LOCAL; import static org.briarproject.bramble.versioning.ClientVersioningConstants.MSG_KEY_UPDATE_VERSION; import static org.junit.Assert.assertEquals; @@ -60,8 +59,6 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { private final ClientId clientId = getClientId(); private final long now = System.currentTimeMillis(); private final Transaction txn = new Transaction(null, false); - private final BdfDictionary groupMeta = BdfDictionary.of( - new BdfEntry(GROUP_KEY_CONTACT_ID, contact.getId().getInt())); private ClientVersioningManagerImpl createInstance() { context.checking(new Expectations() {{ @@ -123,8 +120,8 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { oneOf(db).addGroup(txn, contactGroup); oneOf(db).setGroupVisibility(txn, contact.getId(), contactGroup.getId(), SHARED); - oneOf(clientHelper).mergeGroupMetadata(txn, contactGroup.getId(), - groupMeta); + oneOf(clientHelper).setContactId(txn, contactGroup.getId(), + contact.getId()); oneOf(clock).currentTimeMillis(); will(returnValue(now)); oneOf(clientHelper).createMessage(contactGroup.getId(), now, @@ -460,9 +457,8 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { oneOf(db).deleteMessage(txn, oldRemoteUpdateId); oneOf(db).deleteMessageMetadata(txn, oldRemoteUpdateId); // Get contact ID - oneOf(clientHelper).getGroupMetadataAsDictionary(txn, - contactGroup.getId()); - will(returnValue(groupMeta)); + oneOf(clientHelper).getContactId(txn, contactGroup.getId()); + will(returnValue(contact.getId())); // No states or visibilities have changed }}); @@ -492,10 +488,9 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { // Load the latest local update oneOf(clientHelper).getMessageAsList(txn, oldLocalUpdateId); will(returnValue(oldLocalUpdateBody)); - // Get client ID - oneOf(clientHelper).getGroupMetadataAsDictionary(txn, - contactGroup.getId()); - will(returnValue(groupMeta)); + // Get contact ID + oneOf(clientHelper).getContactId(txn, contactGroup.getId()); + will(returnValue(contact.getId())); // No states or visibilities have changed }}); @@ -546,8 +541,6 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { BdfDictionary newLocalUpdateMeta = BdfDictionary.of( new BdfEntry(MSG_KEY_UPDATE_VERSION, 2L), new BdfEntry(MSG_KEY_LOCAL, true)); - BdfDictionary groupMeta = BdfDictionary.of( - new BdfEntry(GROUP_KEY_CONTACT_ID, contact.getId().getInt())); context.checking(new Expectations() {{ oneOf(clientHelper).toList(newRemoteUpdate); @@ -577,9 +570,8 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { oneOf(clientHelper).addLocalMessage(txn, newLocalUpdate, newLocalUpdateMeta, true, false); // The client's visibility has changed - oneOf(clientHelper).getGroupMetadataAsDictionary(txn, - contactGroup.getId()); - will(returnValue(groupMeta)); + oneOf(clientHelper).getContactId(txn, contactGroup.getId()); + will(returnValue(contact.getId())); oneOf(db).getContact(txn, contact.getId()); will(returnValue(contact)); oneOf(hook).onClientVisibilityChanging(txn, contact, visibility); @@ -619,8 +611,6 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { BdfDictionary newLocalUpdateMeta = BdfDictionary.of( new BdfEntry(MSG_KEY_UPDATE_VERSION, 2L), new BdfEntry(MSG_KEY_LOCAL, true)); - BdfDictionary groupMeta = BdfDictionary.of( - new BdfEntry(GROUP_KEY_CONTACT_ID, contact.getId().getInt())); context.checking(new Expectations() {{ oneOf(clientHelper).toList(newRemoteUpdate); @@ -650,9 +640,8 @@ public class ClientVersioningManagerImplTest extends BrambleMockTestCase { oneOf(clientHelper).addLocalMessage(txn, newLocalUpdate, newLocalUpdateMeta, true, false); // The client's visibility has changed - oneOf(clientHelper).getGroupMetadataAsDictionary(txn, - contactGroup.getId()); - will(returnValue(groupMeta)); + oneOf(clientHelper).getContactId(txn, contactGroup.getId()); + will(returnValue(contact.getId())); oneOf(db).getContact(txn, contact.getId()); will(returnValue(contact)); oneOf(hook).onClientVisibilityChanging(txn, contact, INVISIBLE); diff --git a/bramble-java/src/main/java/org/briarproject/bramble/BrambleJavaModule.java b/bramble-java/src/main/java/org/briarproject/bramble/BrambleJavaModule.java index 6ef05f5f1..f50ea15ae 100644 --- a/bramble-java/src/main/java/org/briarproject/bramble/BrambleJavaModule.java +++ b/bramble-java/src/main/java/org/briarproject/bramble/BrambleJavaModule.java @@ -8,9 +8,9 @@ import org.briarproject.bramble.system.JavaSystemModule; import dagger.Module; @Module(includes = { + CircumventionModule.class, JavaNetworkModule.class, JavaSystemModule.class, - CircumventionModule.class, SocksModule.class }) public class BrambleJavaModule { diff --git a/briar-android/src/androidTestOfficial/java/org/briarproject/briar/android/BriarUiTestComponent.java b/briar-android/src/androidTestOfficial/java/org/briarproject/briar/android/BriarUiTestComponent.java index f4b7a6dee..6071103e0 100644 --- a/briar-android/src/androidTestOfficial/java/org/briarproject/briar/android/BriarUiTestComponent.java +++ b/briar-android/src/androidTestOfficial/java/org/briarproject/briar/android/BriarUiTestComponent.java @@ -3,6 +3,7 @@ package org.briarproject.briar.android; import org.briarproject.bramble.BrambleAndroidModule; import org.briarproject.bramble.BrambleCoreModule; import org.briarproject.bramble.account.BriarAccountModule; +import org.briarproject.bramble.system.ClockModule; import org.briarproject.briar.BriarCoreModule; import org.briarproject.briar.android.account.SignInTestCreateAccount; import org.briarproject.briar.android.account.SignInTestSignIn; @@ -18,6 +19,7 @@ import dagger.Component; @Component(modules = { AppModule.class, AttachmentModule.class, + ClockModule.class, MediaModule.class, BriarCoreModule.class, BrambleAndroidModule.class, diff --git a/briar-android/src/androidTestScreenshot/java/org/briarproject/briar/android/BriarUiTestComponent.java b/briar-android/src/androidTestScreenshot/java/org/briarproject/briar/android/BriarUiTestComponent.java index af6d2ce02..5bed09f0f 100644 --- a/briar-android/src/androidTestScreenshot/java/org/briarproject/briar/android/BriarUiTestComponent.java +++ b/briar-android/src/androidTestScreenshot/java/org/briarproject/briar/android/BriarUiTestComponent.java @@ -3,6 +3,7 @@ package org.briarproject.briar.android; import org.briarproject.bramble.BrambleAndroidModule; import org.briarproject.bramble.BrambleCoreModule; import org.briarproject.bramble.account.BriarAccountModule; +import org.briarproject.bramble.system.ClockModule; import org.briarproject.briar.BriarCoreModule; import org.briarproject.briar.android.attachment.AttachmentModule; import org.briarproject.briar.android.attachment.media.MediaModule; @@ -17,6 +18,7 @@ import dagger.Component; @Component(modules = { AppModule.class, AttachmentModule.class, + ClockModule.class, MediaModule.class, BriarCoreModule.class, BrambleAndroidModule.class, diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java index 5e04f88eb..aeed5817f 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java @@ -29,6 +29,7 @@ import org.briarproject.bramble.api.system.AndroidWakeLockManager; import org.briarproject.bramble.api.system.Clock; import org.briarproject.bramble.api.system.LocationUtils; import org.briarproject.bramble.plugin.tor.CircumventionProvider; +import org.briarproject.bramble.system.ClockModule; import org.briarproject.briar.BriarCoreEagerSingletons; import org.briarproject.briar.BriarCoreModule; import org.briarproject.briar.android.attachment.AttachmentModule; @@ -46,6 +47,7 @@ import org.briarproject.briar.api.android.DozeWatchdog; import org.briarproject.briar.api.android.LockManager; import org.briarproject.briar.api.android.ScreenFilterMonitor; import org.briarproject.briar.api.attachment.AttachmentReader; +import org.briarproject.briar.api.autodelete.AutoDeleteManager; import org.briarproject.briar.api.blog.BlogManager; import org.briarproject.briar.api.blog.BlogPostFactory; import org.briarproject.briar.api.blog.BlogSharingManager; @@ -80,6 +82,7 @@ import dagger.Component; BriarAccountModule.class, AppModule.class, AttachmentModule.class, + ClockModule.class, MediaModule.class }) public interface AndroidComponent @@ -188,6 +191,8 @@ public interface AndroidComponent Thread.UncaughtExceptionHandler exceptionHandler(); + AutoDeleteManager autoDeleteManager(); + void inject(SignInReminderReceiver briarService); void inject(BriarService briarService); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java index 4b007e3a0..f7a175805 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java @@ -37,6 +37,7 @@ import org.briarproject.briar.android.splash.SplashScreenActivity; import org.briarproject.briar.android.util.BriarNotificationBuilder; import org.briarproject.briar.api.android.AndroidNotificationManager; import org.briarproject.briar.api.blog.event.BlogPostAddedEvent; +import org.briarproject.briar.api.conversation.ConversationResponse; import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent; import org.briarproject.briar.api.forum.event.ForumPostReceivedEvent; import org.briarproject.briar.api.privategroup.event.GroupMessageAddedEvent; @@ -226,6 +227,12 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, } else if (e instanceof ConversationMessageReceivedEvent) { ConversationMessageReceivedEvent p = (ConversationMessageReceivedEvent) e; + if (p.getMessageHeader() instanceof ConversationResponse) { + ConversationResponse r = + (ConversationResponse) p.getMessageHeader(); + // don't show notification for own auto-decline responses + if (r.isAutoDecline()) return; + } showContactNotification(p.getContactId()); } else if (e instanceof GroupMessageAddedEvent) { GroupMessageAddedEvent g = (GroupMessageAddedEvent) e; diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java index 6d8507600..9a75f6e2e 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java @@ -293,6 +293,11 @@ public class AppModule { public boolean shouldEnableProfilePictures() { return IS_DEBUG_BUILD; } + + @Override + public boolean shouldEnableDisappearingMessages() { + return IS_DEBUG_BUILD; + } }; } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java index 1ce3bbd76..d1f3ad737 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java @@ -29,6 +29,7 @@ import org.briarproject.briar.android.contact.add.remote.NicknameFragment; import org.briarproject.briar.android.contact.add.remote.PendingContactListActivity; import org.briarproject.briar.android.conversation.AliasDialogFragment; import org.briarproject.briar.android.conversation.ConversationActivity; +import org.briarproject.briar.android.conversation.ConversationSettingsDialog; import org.briarproject.briar.android.conversation.ImageActivity; import org.briarproject.briar.android.conversation.ImageFragment; import org.briarproject.briar.android.forum.CreateForumActivity; @@ -233,4 +234,6 @@ public interface ActivityComponent { void inject(ConfirmAvatarDialogFragment fragment); + void inject(ConversationSettingsDialog dialog); + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java index 003c7efe9..72beffa1a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java @@ -17,6 +17,7 @@ import org.briarproject.briar.android.fragment.BaseFragment; import org.briarproject.briar.android.view.TextInputView; import org.briarproject.briar.android.view.TextSendController; import org.briarproject.briar.android.view.TextSendController.SendListener; +import org.briarproject.briar.android.view.TextSendController.SendState; import org.briarproject.briar.android.widget.LinkDialogFragment; import org.briarproject.briar.api.attachment.AttachmentHeader; @@ -25,6 +26,8 @@ import java.util.List; import javax.annotation.Nullable; import javax.inject.Inject; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModelProvider; import static android.view.View.FOCUS_DOWN; @@ -34,6 +37,7 @@ import static android.view.View.VISIBLE; import static java.util.Objects.requireNonNull; import static org.briarproject.briar.android.activity.BriarActivity.GROUP_ID; import static org.briarproject.briar.android.blog.BlogPostFragment.POST_ID; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; import static org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_POST_TEXT_LENGTH; @MethodsNotNullByDefault @@ -114,11 +118,12 @@ public class ReblogFragment extends BaseFragment implements SendListener { } @Override - public void onSendClick(@Nullable String text, - List headers) { + public LiveData onSendClick(@Nullable String text, + List headers, long expectedAutoDeleteTimer) { ui.input.hideSoftKeyboard(); viewModel.repeatPost(item, text); finish(); + return new MutableLiveData<>(SENT); } private void showProgressBar() { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java index e8532d6a8..e83f71d1d 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/WriteBlogPostActivity.java @@ -31,12 +31,16 @@ import java.util.logging.Logger; import javax.inject.Inject; import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import static android.view.View.GONE; import static android.view.View.VISIBLE; import static java.util.logging.Level.WARNING; import static org.briarproject.bramble.util.LogUtils.logException; import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty; +import static org.briarproject.briar.android.view.TextSendController.SendState; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; import static org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_POST_TEXT_LENGTH; @MethodsNotNullByDefault @@ -112,8 +116,8 @@ public class WriteBlogPostActivity extends BriarActivity } @Override - public void onSendClick(@Nullable String text, - List headers) { + public LiveData onSendClick(@Nullable String text, + List headers, long expectedAutoDeleteTimer) { if (isNullOrEmpty(text)) throw new AssertionError(); // hide publish button, show progress bar @@ -122,6 +126,7 @@ public class WriteBlogPostActivity extends BriarActivity progressBar.setVisibility(VISIBLE); storePost(text); + return new MutableLiveData<>(SENT); } private void storePost(String text) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AliasDialogFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AliasDialogFragment.java index 30df0ecad..8db22b212 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AliasDialogFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AliasDialogFragment.java @@ -31,6 +31,8 @@ import static org.briarproject.briar.android.util.UiUtils.showSoftKeyboard; @MethodsNotNullByDefault @ParametersNotNullByDefault +// TODO: we can probably switch to androidx DialogFragment here but need to +// test this properly public class AliasDialogFragment extends AppCompatDialogFragment { final static String TAG = AliasDialogFragment.class.getName(); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java index 01e0c1647..17da35e55 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java @@ -50,6 +50,7 @@ import org.briarproject.briar.android.blog.BlogActivity; import org.briarproject.briar.android.conversation.ConversationVisitor.AttachmentCache; import org.briarproject.briar.android.conversation.ConversationVisitor.TextCache; import org.briarproject.briar.android.forum.ForumActivity; +import org.briarproject.briar.android.fragment.BaseFragment.BaseFragmentListener; import org.briarproject.briar.android.introduction.IntroductionActivity; import org.briarproject.briar.android.privategroup.conversation.GroupActivity; import org.briarproject.briar.android.util.BriarSnackbarBuilder; @@ -59,8 +60,10 @@ import org.briarproject.briar.android.view.TextAttachmentController; import org.briarproject.briar.android.view.TextAttachmentController.AttachmentListener; import org.briarproject.briar.android.view.TextInputView; import org.briarproject.briar.android.view.TextSendController; +import org.briarproject.briar.android.view.TextSendController.SendState; import org.briarproject.briar.api.android.AndroidNotificationManager; import org.briarproject.briar.api.attachment.AttachmentHeader; +import org.briarproject.briar.api.autodelete.event.ConversationMessagesDeletedEvent; import org.briarproject.briar.api.blog.BlogSharingManager; import org.briarproject.briar.api.client.ProtocolStateException; import org.briarproject.briar.api.client.SessionId; @@ -138,12 +141,14 @@ import static org.briarproject.briar.android.util.UiUtils.observeOnce; import static org.briarproject.briar.android.view.AuthorView.setAvatar; import static org.briarproject.briar.api.messaging.MessagingConstants.MAX_ATTACHMENTS_PER_MESSAGE; import static org.briarproject.briar.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_TEXT_LENGTH; +import static org.briarproject.briar.api.messaging.PrivateMessageFormat.TEXT_IMAGES_AUTO_DELETE; +import static org.briarproject.briar.api.messaging.PrivateMessageFormat.TEXT_ONLY; @MethodsNotNullByDefault @ParametersNotNullByDefault public class ConversationActivity extends BriarActivity - implements EventListener, ConversationListener, TextCache, - AttachmentCache, AttachmentListener, ActionMode.Callback { + implements BaseFragmentListener, EventListener, ConversationListener, + TextCache, AttachmentCache, AttachmentListener, ActionMode.Callback { public static final String CONTACT_ID = "briar.CONTACT_ID"; @@ -268,15 +273,11 @@ public class ConversationActivity extends BriarActivity ImagePreview imagePreview = findViewById(R.id.imagePreview); sendController = new TextAttachmentController(textInputView, imagePreview, this, viewModel); - viewModel.hasImageSupport().observe(this, new Observer() { - @Override - public void onChanged(@Nullable Boolean hasSupport) { - if (hasSupport != null && hasSupport) { - // TODO: remove cast when removing feature flag - ((TextAttachmentController) sendController) - .setImagesSupported(); - viewModel.hasImageSupport().removeObserver(this); - } + observeOnce(viewModel.getPrivateMessageFormat(), this, format -> { + if (format != TEXT_ONLY) { + // TODO: remove cast when removing feature flag + ((TextAttachmentController) sendController) + .setImagesSupported(); } }); } else { @@ -286,6 +287,9 @@ public class ConversationActivity extends BriarActivity textInputView.setMaxTextLength(MAX_PRIVATE_MESSAGE_TEXT_LENGTH); textInputView.setReady(false); textInputView.setOnKeyboardShownListener(this::scrollToBottom); + + viewModel.getAutoDeleteTimer().observe(this, timer -> + sendController.setAutoDeleteTimer(timer)); } private void scrollToBottom() { @@ -369,6 +373,14 @@ public class ConversationActivity extends BriarActivity // enable alias action if available observeOnce(viewModel.getContactItem(), this, contact -> menu.findItem(R.id.action_set_alias).setEnabled(true)); + // Show auto-delete menu item if feature is enabled + if (featureFlags.shouldEnableDisappearingMessages()) { + MenuItem item = menu.findItem(R.id.action_conversation_settings); + item.setVisible(true); + // Enable menu item only if contact supports auto-delete + viewModel.getPrivateMessageFormat().observe(this, format -> + item.setEnabled(format == TEXT_IMAGES_AUTO_DELETE)); + } return super.onCreateOptionsMenu(menu); } @@ -390,6 +402,10 @@ public class ConversationActivity extends BriarActivity AliasDialogFragment.newInstance().show( getSupportFragmentManager(), AliasDialogFragment.TAG); return true; + case R.id.action_conversation_settings: + if (contactId == null) return false; + onAutoDeleteTimerNoticeClicked(); + return true; case R.id.action_delete_all_messages: askToDeleteAllMessages(); return true; @@ -559,7 +575,7 @@ public class ConversationActivity extends BriarActivity this::showImageOnboarding); } List items = createItems(headers); - adapter.addAll(items); + adapter.replaceAll(items); list.showData(); if (layoutManagerState == null) { scrollToBottom(); @@ -640,8 +656,8 @@ public class ConversationActivity extends BriarActivity supportFinishAfterTransition(); } } else if (e instanceof ConversationMessageReceivedEvent) { - ConversationMessageReceivedEvent p = - (ConversationMessageReceivedEvent) e; + ConversationMessageReceivedEvent p = + (ConversationMessageReceivedEvent) e; if (p.getContactId().equals(contactId)) { LOG.info("Message received, adding"); onNewConversationMessage(p.getMessageHeader()); @@ -658,6 +674,13 @@ public class ConversationActivity extends BriarActivity LOG.info("Messages acked"); markMessages(m.getMessageIds(), true, true); } + } else if (e instanceof ConversationMessagesDeletedEvent) { + ConversationMessagesDeletedEvent m = + (ConversationMessagesDeletedEvent) e; + if (m.getContactId().equals(contactId)) { + LOG.info("Messages auto-deleted"); + onConversationMessagesDeleted(m.getMessageIds()); + } } else if (e instanceof ContactConnectedEvent) { ContactConnectedEvent c = (ContactConnectedEvent) e; if (c.getContactId().equals(contactId)) { @@ -705,6 +728,13 @@ public class ConversationActivity extends BriarActivity } } + @UiThread + private void onConversationMessagesDeleted( + Collection messageIds) { + adapter.incrementRevision(); + adapter.removeItems(messageIds); + } + @UiThread private void markMessages(Collection messageIds, boolean sent, boolean seen) { @@ -735,20 +765,13 @@ public class ConversationActivity extends BriarActivity } @Override - public void onSendClick(@Nullable String text, - List attachmentHeaders) { + public LiveData onSendClick(@Nullable String text, + List attachmentHeaders, + long expectedAutoDeleteTimer) { if (isNullOrEmpty(text) && attachmentHeaders.isEmpty()) throw new AssertionError(); - long timestamp = System.currentTimeMillis(); - timestamp = Math.max(timestamp, getMinTimestampForNewMessage()); - viewModel.sendMessage(text, attachmentHeaders, timestamp); - textInputView.clearText(); - } - - private long getMinTimestampForNewMessage() { - // Don't use an earlier timestamp than the newest message - ConversationItem item = adapter.getLastItem(); - return item == null ? 0 : item.getTime() + 1; + return viewModel + .sendMessage(text, attachmentHeaders, expectedAutoDeleteTimer); } private void onAddedPrivateMessage(@Nullable PrivateMessageHeader h) { @@ -823,10 +846,6 @@ public class ConversationActivity extends BriarActivity fails.add(getString( R.string.dialog_message_not_deleted_ongoing_invitations)); } - if (result.hasNotFullyDownloaded()) { - fails.add(getString( - R.string.dialog_message_not_deleted_partly_downloaded)); - } // add problems the user can resolve if (result.hasNotAllIntroductionSelected() && result.hasNotAllInvitationSelected()) { @@ -958,13 +977,11 @@ public class ConversationActivity extends BriarActivity adapter.notifyItemChanged(position, item); } runOnDbThread(() -> { - long timestamp = System.currentTimeMillis(); - timestamp = Math.max(timestamp, getMinTimestampForNewMessage()); try { switch (item.getRequestType()) { case INTRODUCTION: respondToIntroductionRequest(item.getSessionId(), - accept, timestamp); + accept); break; case FORUM: respondToForumRequest(item.getSessionId(), accept); @@ -1038,11 +1055,18 @@ public class ConversationActivity extends BriarActivity ActivityCompat.startActivity(this, i, options.toBundle()); } + @Override + public void onAutoDeleteTimerNoticeClicked() { + ConversationSettingsDialog dialog = + ConversationSettingsDialog.newInstance(contactId); + dialog.show(getSupportFragmentManager(), + ConversationSettingsDialog.TAG); + } + @DatabaseExecutor private void respondToIntroductionRequest(SessionId sessionId, - boolean accept, long time) throws DbException { - introductionManager.respondToIntroduction(contactId, sessionId, time, - accept); + boolean accept) throws DbException { + introductionManager.respondToIntroduction(contactId, sessionId, accept); } @DatabaseExecutor diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java index 1e7065df7..73d2a5f13 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java @@ -13,20 +13,26 @@ import org.briarproject.briar.R; import org.briarproject.briar.android.util.BriarAdapter; import org.briarproject.briar.android.util.ItemReturningAdapter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; +import androidx.annotation.UiThread; import androidx.recyclerview.selection.SelectionTracker; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView.RecycledViewPool; import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; +import static org.briarproject.briar.api.autodelete.AutoDeleteConstants.NO_AUTO_DELETE_TIMER; @NotNullByDefault class ConversationAdapter extends BriarAdapter implements ItemReturningAdapter { - private ConversationListener listener; + private final ConversationListener listener; private final RecycledViewPool imageViewPool; private final ImageItemDecoration imageItemDecoration; @Nullable @@ -65,22 +71,20 @@ class ConversationAdapter @LayoutRes int type) { View v = LayoutInflater.from(viewGroup.getContext()).inflate( type, viewGroup, false); - switch (type) { - case R.layout.list_item_conversation_msg_in: - return new ConversationMessageViewHolder(v, listener, true, - imageViewPool, imageItemDecoration); - case R.layout.list_item_conversation_msg_out: - return new ConversationMessageViewHolder(v, listener, false, - imageViewPool, imageItemDecoration); - case R.layout.list_item_conversation_notice_in: - return new ConversationNoticeViewHolder(v, listener, true); - case R.layout.list_item_conversation_notice_out: - return new ConversationNoticeViewHolder(v, listener, false); - case R.layout.list_item_conversation_request: - return new ConversationRequestViewHolder(v, listener, true); - default: - throw new IllegalArgumentException("Unknown ConversationItem"); + if (type == R.layout.list_item_conversation_msg_in) { + return new ConversationMessageViewHolder(v, listener, true, + imageViewPool, imageItemDecoration); + } else if (type == R.layout.list_item_conversation_msg_out) { + return new ConversationMessageViewHolder(v, listener, false, + imageViewPool, imageItemDecoration); + } else if (type == R.layout.list_item_conversation_notice_in) { + return new ConversationNoticeViewHolder(v, listener, true); + } else if (type == R.layout.list_item_conversation_notice_out) { + return new ConversationNoticeViewHolder(v, listener, false); + } else if (type == R.layout.list_item_conversation_request) { + return new ConversationRequestViewHolder(v, listener, true); } + throw new IllegalArgumentException("Unknown ConversationItem"); } @Override @@ -107,22 +111,65 @@ class ConversationAdapter return c1.equals(c2); } + @Override + public void add(ConversationItem item) { + items.beginBatchedUpdates(); + items.add(item); + updateTimersInBatch(); + items.endBatchedUpdates(); + } + + @Override + public void replaceAll(Collection itemsToReplace) { + items.beginBatchedUpdates(); + // there can be items already in the adapter + // SortedList takes care of duplicates and detecting changed items + items.replaceAll(itemsToReplace); + updateTimersInBatch(); + items.endBatchedUpdates(); + } + + @UiThread + void removeItems(Collection messageIds) { + // Collect all items to be deleted first + // and then delete them in one batched update. + // Deleting them right away would cause issues + // due to changing list positions. + List toRemove = new ArrayList<>(messageIds.size()); + for (int i = 0; i < items.size(); i++) { + ConversationItem item = items.get(i); + if (messageIds.contains(item.getId())) toRemove.add(item); + } + items.beginBatchedUpdates(); + for (ConversationItem item : toRemove) items.remove(item); + items.endBatchedUpdates(); + } + + private void updateTimersInBatch() { + long lastTimerIncoming = NO_AUTO_DELETE_TIMER; + long lastTimerOutgoing = NO_AUTO_DELETE_TIMER; + for (int i = 0; i < items.size(); i++) { + ConversationItem c = items.get(i); + boolean itemChanged; + boolean timerChanged; + if (c.isIncoming()) { + timerChanged = lastTimerIncoming != c.getAutoDeleteTimer(); + lastTimerIncoming = c.getAutoDeleteTimer(); + } else { + timerChanged = lastTimerOutgoing != c.getAutoDeleteTimer(); + lastTimerOutgoing = c.getAutoDeleteTimer(); + } + itemChanged = c.setTimerNoticeVisible(timerChanged); + if (itemChanged) items.updateItemAt(i, c); + } + } + void setSelectionTracker(SelectionTracker tracker) { this.tracker = tracker; } - @Nullable - ConversationItem getLastItem() { - if (items.size() > 0) { - return items.get(items.size() - 1); - } else { - return null; - } - } - SparseArray getOutgoingMessages() { SparseArray messages = new SparseArray<>(); - for (int i = 0; i < items.size(); i++) { ConversationItem item = items.get(i); if (!item.isIncoming()) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java index a0cd5d54b..c3d96e462 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java @@ -9,6 +9,7 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; import androidx.annotation.LayoutRes; +import androidx.lifecycle.LiveData; import static org.briarproject.bramble.util.StringUtils.toHexString; @@ -22,20 +23,25 @@ abstract class ConversationItem { protected String text; private final MessageId id; private final GroupId groupId; - private final long time; + private final long time, autoDeleteTimer; private final boolean isIncoming; - private boolean read, sent, seen; + private final LiveData contactName; + private boolean read, sent, seen, showTimerNotice; - ConversationItem(@LayoutRes int layoutRes, ConversationMessageHeader h) { + ConversationItem(@LayoutRes int layoutRes, ConversationMessageHeader h, + LiveData contactName) { this.layoutRes = layoutRes; this.text = null; this.id = h.getId(); this.groupId = h.getGroupId(); this.time = h.getTimestamp(); + this.autoDeleteTimer = h.getAutoDeleteTimer(); this.read = h.isRead(); this.sent = h.isSent(); this.seen = h.isSeen(); this.isIncoming = !h.isLocal(); + this.contactName = contactName; + this.showTimerNotice = false; } @LayoutRes @@ -68,6 +74,10 @@ abstract class ConversationItem { return time; } + public long getAutoDeleteTimer() { + return autoDeleteTimer; + } + /** * Only useful for incoming messages. */ @@ -111,4 +121,25 @@ abstract class ConversationItem { return isIncoming; } + public LiveData getContactName() { + return contactName; + } + + /** + * Set this to true when {@link #getAutoDeleteTimer()} has changed + * since the last message from the same peer. + * + * @return true if the value was set, false if it was already set. + */ + boolean setTimerNoticeVisible(boolean visible) { + if (this.showTimerNotice != visible) { + this.showTimerNotice = visible; + return true; + } + return false; + } + + boolean isTimerNoticeVisible() { + return showTimerNotice; + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java index 6a54584b4..c09a2cbf7 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java @@ -1,6 +1,8 @@ package org.briarproject.briar.android.conversation; +import android.content.Context; import android.view.View; +import android.widget.ImageView; import android.widget.TextView; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; @@ -12,8 +14,12 @@ import androidx.annotation.UiThread; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.recyclerview.widget.RecyclerView.ViewHolder; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; import static org.briarproject.bramble.util.StringUtils.trim; import static org.briarproject.briar.android.util.UiUtils.formatDate; +import static org.briarproject.briar.android.util.UiUtils.formatDuration; +import static org.briarproject.briar.api.autodelete.AutoDeleteConstants.NO_AUTO_DELETE_TIMER; @UiThread @NotNullByDefault @@ -24,8 +30,9 @@ abstract class ConversationItemViewHolder extends ViewHolder { protected final ConstraintLayout layout; @Nullable private final OutItemViewHolder outViewHolder; - private final TextView text; + private final TextView topNotice, text; protected final TextView time; + protected final ImageView bomb; @Nullable private String itemKey = null; @@ -33,11 +40,13 @@ abstract class ConversationItemViewHolder extends ViewHolder { boolean isIncoming) { super(v); this.listener = listener; - this.outViewHolder = isIncoming ? null : new OutItemViewHolder(v); + outViewHolder = isIncoming ? null : new OutItemViewHolder(v); root = v; + topNotice = v.findViewById(R.id.topNotice); layout = v.findViewById(R.id.layout); text = v.findViewById(R.id.text); time = v.findViewById(R.id.time); + bomb = v.findViewById(R.id.bomb); } @CallSuper @@ -45,6 +54,8 @@ abstract class ConversationItemViewHolder extends ViewHolder { itemKey = item.getKey(); root.setActivated(selected); + setTopNotice(item); + if (item.getText() != null) { text.setText(trim(item.getText())); } @@ -52,6 +63,9 @@ abstract class ConversationItemViewHolder extends ViewHolder { long timestamp = item.getTime(); time.setText(formatDate(time.getContext(), timestamp)); + boolean showBomb = item.getAutoDeleteTimer() != NO_AUTO_DELETE_TIMER; + bomb.setVisibility(showBomb ? VISIBLE : GONE); + if (outViewHolder != null) outViewHolder.bind(item); } @@ -64,4 +78,35 @@ abstract class ConversationItemViewHolder extends ViewHolder { return itemKey; } + private void setTopNotice(ConversationItem item) { + if (item.isTimerNoticeVisible()) { + Context ctx = itemView.getContext(); + topNotice.setVisibility(VISIBLE); + boolean enabled = item.getAutoDeleteTimer() != NO_AUTO_DELETE_TIMER; + String duration = enabled ? + formatDuration(ctx, item.getAutoDeleteTimer()) : ""; + String tapToLearnMore = ctx.getString(R.string.tap_to_learn_more); + String text; + if (item.isIncoming()) { + String name = item.getContactName().getValue(); + text = enabled ? + ctx.getString(R.string.auto_delete_msg_contact_enabled, + name, duration, tapToLearnMore) : + ctx.getString(R.string.auto_delete_msg_contact_disabled, + name, tapToLearnMore); + } else { + text = enabled ? + ctx.getString(R.string.auto_delete_msg_you_enabled, + duration, tapToLearnMore) : + ctx.getString(R.string.auto_delete_msg_you_disabled, + tapToLearnMore); + } + topNotice.setText(text); + topNotice.setOnClickListener( + v -> listener.onAutoDeleteTimerNoticeClicked()); + } else { + topNotice.setVisibility(GONE); + } + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationListener.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationListener.java index 700608e3a..65a25e343 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationListener.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationListener.java @@ -18,4 +18,6 @@ interface ConversationListener { void onAttachmentClicked(View view, ConversationMessageItem messageItem, AttachmentItem attachmentItem); + void onAutoDeleteTimerNoticeClicked(); + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java index 8fc50d299..b9185c1ce 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageItem.java @@ -10,6 +10,7 @@ import javax.annotation.concurrent.NotThreadSafe; import androidx.annotation.LayoutRes; import androidx.annotation.UiThread; +import androidx.lifecycle.LiveData; @NotThreadSafe @NotNullByDefault @@ -18,8 +19,8 @@ class ConversationMessageItem extends ConversationItem { private final List attachments; ConversationMessageItem(@LayoutRes int layoutRes, PrivateMessageHeader h, - List attachments) { - super(layoutRes, h); + LiveData contactName, List attachments) { + super(layoutRes, h, contactName); this.attachments = attachments; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java index acee25a9f..5baed8f1c 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java @@ -1,5 +1,6 @@ package org.briarproject.briar.android.conversation; +import android.content.res.ColorStateList; import android.view.View; import android.view.ViewGroup; @@ -14,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView.RecycledViewPool; import static androidx.constraintlayout.widget.ConstraintSet.WRAP_CONTENT; import static androidx.core.content.ContextCompat.getColor; +import static androidx.core.widget.ImageViewCompat.setImageTintList; @UiThread @NotNullByDefault @@ -84,6 +86,7 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder { if (item.getText() == null) { statusLayout.setBackgroundResource(R.drawable.msg_status_bubble); time.setTextColor(timeColorBubble); + setImageTintList(bomb, ColorStateList.valueOf(timeColorBubble)); constraintSet = imageConstraints; } else { resetStatusLayoutForText(); @@ -111,6 +114,7 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder { // also reset padding (the background drawable defines some) statusLayout.setPadding(0, 0, 0, 0); time.setTextColor(timeColor); + setImageTintList(bomb, ColorStateList.valueOf(timeColor)); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeItem.java index 0694a0762..50400017a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeItem.java @@ -8,6 +8,7 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; import androidx.annotation.LayoutRes; +import androidx.lifecycle.LiveData; @NotThreadSafe @NotNullByDefault @@ -17,15 +18,15 @@ class ConversationNoticeItem extends ConversationItem { private final String msgText; ConversationNoticeItem(@LayoutRes int layoutRes, String text, - ConversationRequest r) { - super(layoutRes, r); + LiveData contactName, ConversationRequest r) { + super(layoutRes, r, contactName); this.text = text; this.msgText = r.getText(); } ConversationNoticeItem(@LayoutRes int layoutRes, String text, - ConversationResponse r) { - super(layoutRes, r); + LiveData contactName, ConversationResponse r) { + super(layoutRes, r, contactName); this.text = text; this.msgText = null; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestItem.java index 71984db65..5f7027d63 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestItem.java @@ -11,6 +11,7 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; import androidx.annotation.LayoutRes; +import androidx.lifecycle.LiveData; @NotThreadSafe @NotNullByDefault @@ -26,14 +27,15 @@ class ConversationRequestItem extends ConversationNoticeItem { private boolean answered; ConversationRequestItem(@LayoutRes int layoutRes, String text, - RequestType type, ConversationRequest r) { - super(layoutRes, text, r); + LiveData contactName, RequestType type, + ConversationRequest r) { + super(layoutRes, text, contactName, r); this.requestType = type; this.sessionId = r.getSessionId(); this.answered = r.wasAnswered(); if (r instanceof InvitationRequest) { this.requestedGroupId = ((Shareable) r.getNameable()).getId(); - this.canBeOpened = ((InvitationRequest) r).canBeOpened(); + this.canBeOpened = ((InvitationRequest) r).canBeOpened(); } else { this.requestedGroupId = null; this.canBeOpened = false; diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationSettingsDialog.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationSettingsDialog.java new file mode 100644 index 000000000..f55d4c6d9 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationSettingsDialog.java @@ -0,0 +1,126 @@ +package org.briarproject.briar.android.conversation; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import org.briarproject.bramble.api.contact.ContactId; +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.activity.ActivityComponent; +import org.briarproject.briar.android.fragment.BaseFragment; +import org.briarproject.briar.android.widget.OnboardingFullDialogFragment; + +import java.util.logging.Logger; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static java.util.logging.Level.INFO; +import static org.briarproject.briar.android.conversation.ConversationActivity.CONTACT_ID; +import static org.briarproject.briar.api.autodelete.AutoDeleteConstants.NO_AUTO_DELETE_TIMER; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ConversationSettingsDialog extends DialogFragment { + + final static String TAG = ConversationSettingsDialog.class.getName(); + + private static final Logger LOG = Logger.getLogger(TAG); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private ConversationViewModel viewModel; + + static ConversationSettingsDialog newInstance(ContactId contactId) { + Bundle args = new Bundle(); + args.putInt(CONTACT_ID, contactId.getInt()); + ConversationSettingsDialog dialog = new ConversationSettingsDialog(); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + injectFragment(((BaseFragment.BaseFragmentListener) context) + .getActivityComponent()); + } + + public void injectFragment(ActivityComponent component) { + component.inject(this); + viewModel = new ViewModelProvider(requireActivity(), viewModelFactory) + .get(ConversationViewModel.class); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NO_FRAME, + R.style.BriarFullScreenDialogTheme); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_conversation_settings, + container, false); + + Bundle args = requireArguments(); + int id = args.getInt(CONTACT_ID, -1); + if (id == -1) throw new IllegalStateException(); + ContactId contactId = new ContactId(id); + + FragmentActivity activity = requireActivity(); + viewModel = new ViewModelProvider(activity, viewModelFactory) + .get(ConversationViewModel.class); + viewModel.setContactId(contactId); + + Toolbar toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(v -> dismiss()); + + SwitchCompat switchDisappearingMessages = view.findViewById( + R.id.switchDisappearingMessages); + switchDisappearingMessages.setOnCheckedChangeListener( + (button, value) -> viewModel.setAutoDeleteTimerEnabled(value)); + + Button buttonLearnMore = + view.findViewById(R.id.buttonLearnMore); + buttonLearnMore.setOnClickListener(e -> showLearnMoreDialog()); + + viewModel.getAutoDeleteTimer() + .observe(getViewLifecycleOwner(), timer -> { + if (LOG.isLoggable(INFO)) { + LOG.info("Received auto delete timer: " + timer); + } + boolean disappearingMessages = + timer != NO_AUTO_DELETE_TIMER; + switchDisappearingMessages + .setChecked(disappearingMessages); + switchDisappearingMessages.setEnabled(true); + }); + + return view; + } + + private void showLearnMoreDialog() { + OnboardingFullDialogFragment.newInstance( + R.string.disappearing_messages_title, + R.string.disappearing_messages_explanation_long + ).show(getChildFragmentManager(), OnboardingFullDialogFragment.TAG); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java index 2a7e23dc7..1d477da11 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationViewModel.java @@ -10,6 +10,7 @@ import org.briarproject.bramble.api.contact.ContactManager; import org.briarproject.bramble.api.db.DatabaseExecutor; import org.briarproject.bramble.api.db.DbException; import org.briarproject.bramble.api.db.NoSuchContactException; +import org.briarproject.bramble.api.db.Transaction; import org.briarproject.bramble.api.db.TransactionManager; import org.briarproject.bramble.api.event.Event; import org.briarproject.bramble.api.event.EventBus; @@ -28,16 +29,22 @@ import org.briarproject.briar.android.attachment.AttachmentResult; import org.briarproject.briar.android.attachment.AttachmentRetriever; import org.briarproject.briar.android.contact.ContactItem; import org.briarproject.briar.android.util.UiUtils; +import org.briarproject.briar.android.view.TextSendController.SendState; import org.briarproject.briar.android.viewmodel.DbViewModel; import org.briarproject.briar.android.viewmodel.LiveEvent; import org.briarproject.briar.android.viewmodel.MutableLiveEvent; import org.briarproject.briar.api.attachment.AttachmentHeader; +import org.briarproject.briar.api.autodelete.AutoDeleteManager; +import org.briarproject.briar.api.autodelete.UnexpectedTimerException; +import org.briarproject.briar.api.autodelete.event.AutoDeleteTimerMirroredEvent; import org.briarproject.briar.api.avatar.event.AvatarUpdatedEvent; +import org.briarproject.briar.api.conversation.ConversationManager; import org.briarproject.briar.api.identity.AuthorInfo; import org.briarproject.briar.api.identity.AuthorManager; import org.briarproject.briar.api.messaging.MessagingManager; import org.briarproject.briar.api.messaging.PrivateMessage; import org.briarproject.briar.api.messaging.PrivateMessageFactory; +import org.briarproject.briar.api.messaging.PrivateMessageFormat; import org.briarproject.briar.api.messaging.PrivateMessageHeader; import org.briarproject.briar.api.messaging.event.AttachmentReceivedEvent; @@ -55,6 +62,7 @@ import androidx.lifecycle.MutableLiveData; import static androidx.lifecycle.Transformations.map; import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.INFO; import static java.util.logging.Level.WARNING; import static java.util.logging.Logger.getLogger; import static org.briarproject.bramble.util.LogUtils.logDuration; @@ -62,6 +70,13 @@ import static org.briarproject.bramble.util.LogUtils.logException; import static org.briarproject.bramble.util.LogUtils.now; import static org.briarproject.briar.android.settings.SettingsFragment.SETTINGS_NAMESPACE; import static org.briarproject.briar.android.util.UiUtils.observeForeverOnce; +import static org.briarproject.briar.android.view.TextSendController.SendState.ERROR; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; +import static org.briarproject.briar.android.view.TextSendController.SendState.UNEXPECTED_TIMER; +import static org.briarproject.briar.api.autodelete.AutoDeleteConstants.NO_AUTO_DELETE_TIMER; +import static org.briarproject.briar.api.autodelete.AutoDeleteManager.DEFAULT_TIMER_DURATION; +import static org.briarproject.briar.api.messaging.PrivateMessageFormat.TEXT_IMAGES; +import static org.briarproject.briar.api.messaging.PrivateMessageFormat.TEXT_ONLY; @NotNullByDefault public class ConversationViewModel extends DbViewModel @@ -84,6 +99,8 @@ public class ConversationViewModel extends DbViewModel private final PrivateMessageFactory privateMessageFactory; private final AttachmentRetriever attachmentRetriever; private final AttachmentCreator attachmentCreator; + private final AutoDeleteManager autoDeleteManager; + private final ConversationManager conversationManager; @Nullable private ContactId contactId = null; @@ -92,7 +109,7 @@ public class ConversationViewModel extends DbViewModel private final LiveData contactName = map(contactItem, c -> UiUtils.getContactDisplayName(c.getContact())); private final LiveData messagingGroupId; - private final MutableLiveData imageSupport = + private final MutableLiveData privateMessageFormat = new MutableLiveData<>(); private final MutableLiveEvent showImageOnboarding = new MutableLiveEvent<>(); @@ -100,8 +117,10 @@ public class ConversationViewModel extends DbViewModel new MutableLiveEvent<>(); private final MutableLiveData showIntroductionAction = new MutableLiveData<>(); - private final MutableLiveData contactDeleted = + private final MutableLiveData autoDeleteTimer = new MutableLiveData<>(); + private final MutableLiveData contactDeleted = + new MutableLiveData<>(false); private final MutableLiveEvent addedHeader = new MutableLiveEvent<>(); @@ -118,7 +137,9 @@ public class ConversationViewModel extends DbViewModel SettingsManager settingsManager, PrivateMessageFactory privateMessageFactory, AttachmentRetriever attachmentRetriever, - AttachmentCreator attachmentCreator) { + AttachmentCreator attachmentCreator, + AutoDeleteManager autoDeleteManager, + ConversationManager conversationManager) { super(application, dbExecutor, lifecycleManager, db, androidExecutor); this.db = db; this.eventBus = eventBus; @@ -129,10 +150,10 @@ public class ConversationViewModel extends DbViewModel this.privateMessageFactory = privateMessageFactory; this.attachmentRetriever = attachmentRetriever; this.attachmentCreator = attachmentCreator; + this.autoDeleteManager = autoDeleteManager; + this.conversationManager = conversationManager; messagingGroupId = map(contactItem, c -> messagingManager.getContactGroup(c.getContact()).getId()); - contactDeleted.setValue(false); - eventBus.addListener(this); } @@ -152,6 +173,11 @@ public class ConversationViewModel extends DbViewModel runOnDbThread(() -> attachmentRetriever .loadAttachmentItem(a.getMessageId())); } + } else if (e instanceof AutoDeleteTimerMirroredEvent) { + AutoDeleteTimerMirroredEvent a = (AutoDeleteTimerMirroredEvent) e; + if (a.getContactId().equals(contactId)) { + autoDeleteTimer.setValue(a.getNewTimer()); + } } else if (e instanceof AvatarUpdatedEvent) { AvatarUpdatedEvent a = (AvatarUpdatedEvent) e; if (a.getContactId().equals(contactId)) { @@ -201,6 +227,11 @@ public class ConversationViewModel extends DbViewModel contactItem.postValue(new ContactItem(c, authorInfo)); logDuration(LOG, "Loading contact", start); start = now(); + long timer = db.transactionWithResult(true, txn -> + autoDeleteManager.getAutoDeleteTimer(txn, contactId)); + autoDeleteTimer.postValue(timer); + logDuration(LOG, "Getting auto-delete timer", start); + start = now(); checkFeaturesAndOnboarding(contactId); logDuration(LOG, "Checking for image support", start); } catch (NoSuchContactException e) { @@ -215,7 +246,7 @@ public class ConversationViewModel extends DbViewModel runOnDbThread(() -> { try { long start = now(); - messagingManager.setReadFlag(g, m, true); + conversationManager.setReadFlag(g, m, true); logDuration(LOG, "Marking read", start); } catch (DbException e) { logException(LOG, WARNING, e); @@ -235,20 +266,6 @@ public class ConversationViewModel extends DbViewModel }); } - @UiThread - void sendMessage(@Nullable String text, - List headers, long timestamp) { - // messagingGroupId is loaded with the contact - observeForeverOnce(messagingGroupId, groupId -> { - requireNonNull(groupId); - observeForeverOnce(imageSupport, hasImageSupport -> { - requireNonNull(hasImageSupport); - createMessage(groupId, text, headers, timestamp, - hasImageSupport); - }); - }); - } - @Override @UiThread public LiveData storeAttachments(Collection uris, @@ -275,10 +292,12 @@ public class ConversationViewModel extends DbViewModel @DatabaseExecutor private void checkFeaturesAndOnboarding(ContactId c) throws DbException { - // check if images are supported - boolean imagesSupported = db.transactionWithResult(true, txn -> - messagingManager.contactSupportsImages(txn, c)); - imageSupport.postValue(imagesSupported); + // check if images and auto-deletion are supported + PrivateMessageFormat format = db.transactionWithResult(true, txn -> + messagingManager.getContactMessageFormat(txn, c)); + if (LOG.isLoggable(INFO)) + LOG.info("PrivateMessageFormat loaded: " + format.name()); + privateMessageFormat.postValue(format); // check if introductions are supported Collection contacts = contactManager.getContacts(); @@ -287,7 +306,7 @@ public class ConversationViewModel extends DbViewModel // we only show one onboarding dialog at a time Settings settings = settingsManager.getSettings(SETTINGS_NAMESPACE); - if (imagesSupported && + if (format != TEXT_ONLY && settings.getBoolean(SHOW_ONBOARDING_IMAGE, true)) { onOnboardingShown(SHOW_ONBOARDING_IMAGE); showImageOnboarding.postEvent(true); @@ -306,39 +325,82 @@ public class ConversationViewModel extends DbViewModel } @UiThread - private void createMessage(GroupId groupId, @Nullable String text, - List headers, long timestamp, - boolean hasImageSupport) { + LiveData sendMessage(@Nullable String text, + List headers, long expectedTimer) { + MutableLiveData liveData = new MutableLiveData<>(); + runOnDbThread(() -> { + try { + db.transaction(false, txn -> { + long start = now(); + PrivateMessage m = createMessage(txn, text, headers, + expectedTimer); + messagingManager.addLocalMessage(txn, m); + logDuration(LOG, "Storing message", start); + Message message = m.getMessage(); + PrivateMessageHeader h = new PrivateMessageHeader( + message.getId(), message.getGroupId(), + message.getTimestamp(), true, true, false, false, + m.hasText(), m.getAttachmentHeaders(), + m.getAutoDeleteTimer()); + // TODO add text to cache when available here + MessageId id = message.getId(); + txn.attach(() -> { + attachmentCreator.onAttachmentsSent(id); + liveData.setValue(SENT); + addedHeader.setEvent(h); + }); + }); + } catch (UnexpectedTimerException e) { + liveData.postValue(UNEXPECTED_TIMER); + } catch (DbException e) { + logException(LOG, WARNING, e); + liveData.postValue(ERROR); + } + }); + return liveData; + } + + private PrivateMessage createMessage(Transaction txn, @Nullable String text, + List headers, long expectedTimer) + throws DbException { + // Sending is only possible (setReady(true)) after loading all messages + // which happens after the contact has been loaded. + // privateMessageFormat is loaded together with contact + Contact contact = requireNonNull(contactItem.getValue()).getContact(); + GroupId groupId = messagingManager.getContactGroup(contact).getId(); + PrivateMessageFormat format = + requireNonNull(privateMessageFormat.getValue()); + long timestamp = conversationManager + .getTimestampForOutgoingMessage(txn, requireNonNull(contactId)); try { - PrivateMessage pm; - if (hasImageSupport) { - pm = privateMessageFactory.createPrivateMessage(groupId, + if (format == TEXT_ONLY) { + return privateMessageFactory.createLegacyPrivateMessage( + groupId, timestamp, requireNonNull(text)); + } else if (format == TEXT_IMAGES) { + return privateMessageFactory.createPrivateMessage(groupId, timestamp, text, headers); } else { - pm = privateMessageFactory.createLegacyPrivateMessage( - groupId, timestamp, requireNonNull(text)); + long timer = autoDeleteManager + .getAutoDeleteTimer(txn, contactId, timestamp); + if (timer != expectedTimer) + throw new UnexpectedTimerException(); + return privateMessageFactory.createPrivateMessage(groupId, + timestamp, text, headers, timer); } - storeMessage(pm); } catch (FormatException e) { throw new AssertionError(e); } } - @UiThread - private void storeMessage(PrivateMessage m) { - attachmentCreator.onAttachmentsSent(m.getMessage().getId()); + void setAutoDeleteTimerEnabled(boolean enabled) { + long timer = enabled ? DEFAULT_TIMER_DURATION : NO_AUTO_DELETE_TIMER; + // ContactId is set before menu gets inflated and UI interaction + final ContactId c = requireNonNull(contactId); runOnDbThread(() -> { try { - long start = now(); - messagingManager.addLocalMessage(m); - logDuration(LOG, "Storing message", start); - Message message = m.getMessage(); - PrivateMessageHeader h = new PrivateMessageHeader( - message.getId(), message.getGroupId(), - message.getTimestamp(), true, true, false, false, - m.hasText(), m.getAttachmentHeaders()); - // TODO add text to cache when available here - addedHeader.postEvent(h); + db.transaction(false, txn -> + autoDeleteManager.setAutoDeleteTimer(txn, c, timer)); + autoDeleteTimer.postValue(timer); } catch (DbException e) { logException(LOG, WARNING, e); } @@ -357,8 +419,8 @@ public class ConversationViewModel extends DbViewModel return contactName; } - LiveData hasImageSupport() { - return imageSupport; + LiveData getPrivateMessageFormat() { + return privateMessageFormat; } LiveEvent showImageOnboarding() { @@ -373,6 +435,10 @@ public class ConversationViewModel extends DbViewModel return showIntroductionAction; } + LiveData getAutoDeleteTimer() { + return autoDeleteTimer; + } + LiveData isContactDeleted() { return contactDeleted; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java index 46468c030..061a57ccb 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationVisitor.java @@ -60,10 +60,12 @@ class ConversationVisitor implements } if (h.isLocal()) { item = new ConversationMessageItem( - R.layout.list_item_conversation_msg_out, h, attachments); + R.layout.list_item_conversation_msg_out, h, contactName, + attachments); } else { item = new ConversationMessageItem( - R.layout.list_item_conversation_msg_in, h, attachments); + R.layout.list_item_conversation_msg_in, h, contactName, + attachments); } if (h.hasText()) { String text = textCache.getText(h.getId()); @@ -79,13 +81,15 @@ class ConversationVisitor implements String text = ctx.getString(R.string.blogs_sharing_invitation_sent, r.getName(), contactName.getValue()); return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text = ctx.getString( R.string.blogs_sharing_invitation_received, contactName.getValue(), r.getName()); return new ConversationRequestItem( - R.layout.list_item_conversation_request, text, BLOG, r); + R.layout.list_item_conversation_request, text, contactName, + BLOG, r); } } @@ -98,13 +102,18 @@ class ConversationVisitor implements text = ctx.getString( R.string.blogs_sharing_response_accepted_sent, contactName.getValue()); + } else if (r.isAutoDecline()) { + text = ctx.getString( + R.string.blogs_sharing_response_declined_auto, + contactName.getValue()); } else { text = ctx.getString( R.string.blogs_sharing_response_declined_sent, contactName.getValue()); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text; if (r.wasAccepted()) { @@ -117,7 +126,8 @@ class ConversationVisitor implements contactName.getValue()); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_in, text, r); + R.layout.list_item_conversation_notice_in, text, + contactName, r); } } @@ -128,13 +138,15 @@ class ConversationVisitor implements String text = ctx.getString(R.string.forum_invitation_sent, r.getName(), contactName.getValue()); return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text = ctx.getString( R.string.forum_invitation_received, contactName.getValue(), r.getName()); return new ConversationRequestItem( - R.layout.list_item_conversation_request, text, FORUM, r); + R.layout.list_item_conversation_request, text, contactName, + FORUM, r); } } @@ -147,13 +159,18 @@ class ConversationVisitor implements text = ctx.getString( R.string.forum_invitation_response_accepted_sent, contactName.getValue()); + } else if (r.isAutoDecline()) { + text = ctx.getString( + R.string.forum_invitation_response_declined_auto, + contactName.getValue()); } else { text = ctx.getString( R.string.forum_invitation_response_declined_sent, contactName.getValue()); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text; if (r.wasAccepted()) { @@ -166,7 +183,8 @@ class ConversationVisitor implements contactName.getValue()); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_in, text, r); + R.layout.list_item_conversation_notice_in, text, + contactName, r); } } @@ -178,13 +196,15 @@ class ConversationVisitor implements R.string.groups_invitations_invitation_sent, contactName.getValue(), r.getName()); return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text = ctx.getString( R.string.groups_invitations_invitation_received, contactName.getValue(), r.getName()); return new ConversationRequestItem( - R.layout.list_item_conversation_request, text, GROUP, r); + R.layout.list_item_conversation_request, text, contactName, + GROUP, r); } } @@ -197,13 +217,18 @@ class ConversationVisitor implements text = ctx.getString( R.string.groups_invitations_response_accepted_sent, contactName.getValue()); + } else if (r.isAutoDecline()) { + text = ctx.getString( + R.string.groups_invitations_response_declined_auto, + contactName.getValue()); } else { text = ctx.getString( R.string.groups_invitations_response_declined_sent, contactName.getValue()); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text; if (r.wasAccepted()) { @@ -216,7 +241,8 @@ class ConversationVisitor implements contactName.getValue()); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_in, text, r); + R.layout.list_item_conversation_notice_in, text, + contactName, r); } } @@ -227,7 +253,8 @@ class ConversationVisitor implements String text = ctx.getString(R.string.introduction_request_sent, contactName.getValue(), name); return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text; if (r.wasAnswered()) { @@ -243,7 +270,7 @@ class ConversationVisitor implements contactName.getValue(), name); } return new ConversationRequestItem( - R.layout.list_item_conversation_request, text, + R.layout.list_item_conversation_request, text, contactName, INTRODUCTION, r); } } @@ -262,13 +289,18 @@ class ConversationVisitor implements text = ctx.getString( R.string.introduction_response_accepted_sent, introducedAuthor) + suffix; + } else if (r.isAutoDecline()) { + text = ctx.getString( + R.string.introduction_response_declined_auto, + introducedAuthor); } else { text = ctx.getString( R.string.introduction_response_declined_sent, introducedAuthor); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_out, text, r); + R.layout.list_item_conversation_notice_out, text, + contactName, r); } else { String text; if (r.wasAccepted()) { @@ -288,7 +320,8 @@ class ConversationVisitor implements introducedAuthor); } return new ConversationNoticeItem( - R.layout.list_item_conversation_notice_in, text, r); + R.layout.list_item_conversation_notice_in, text, + contactName, r); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java index 8c2efe46f..d5ed65d33 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionMessageFragment.java @@ -25,6 +25,8 @@ import javax.inject.Inject; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModelProvider; import de.hdodenhof.circleimageview.CircleImageView; @@ -34,6 +36,8 @@ import static android.view.View.VISIBLE; import static org.briarproject.briar.android.util.UiUtils.getContactDisplayName; import static org.briarproject.briar.android.util.UiUtils.hideSoftKeyboard; import static org.briarproject.briar.android.view.AuthorView.setAvatar; +import static org.briarproject.briar.android.view.TextSendController.SendState; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; import static org.briarproject.briar.api.introduction.IntroductionConstants.MAX_INTRODUCTION_TEXT_LENGTH; @MethodsNotNullByDefault @@ -129,8 +133,8 @@ public class IntroductionMessageFragment extends BaseFragment } @Override - public void onSendClick(@Nullable String text, - List headers) { + public LiveData onSendClick(@Nullable String text, + List headers, long expectedAutoDeleteTimer) { // disable button to prevent accidental double invitations ui.message.setReady(false); @@ -141,6 +145,7 @@ public class IntroductionMessageFragment extends BaseFragment FragmentActivity activity = requireActivity(); activity.setResult(RESULT_OK); activity.supportFinishAfterTransition(); + return new MutableLiveData<>(SENT); } private static class ViewHolder { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionViewModel.java index 94ea3a4f3..399c4f64d 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/introduction/IntroductionViewModel.java @@ -165,10 +165,9 @@ class IntroductionViewModel extends ContactsViewModel { runOnDbThread(() -> { // actually make the introduction try { - long timestamp = System.currentTimeMillis(); introductionManager.makeIntroduction( info.getContact1().getContact(), - info.getContact2().getContact(), text, timestamp); + info.getContact2().getContact(), text); } catch (DbException e) { logException(LOG, WARNING, e); androidExecutor.runOnUiThread(() -> Toast.makeText( diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/creation/CreateGroupControllerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/creation/CreateGroupControllerImpl.java index 809f224e6..4bae607e8 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/creation/CreateGroupControllerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/creation/CreateGroupControllerImpl.java @@ -7,6 +7,8 @@ import org.briarproject.bramble.api.crypto.CryptoExecutor; import org.briarproject.bramble.api.db.DatabaseExecutor; import org.briarproject.bramble.api.db.DbException; import org.briarproject.bramble.api.db.NoSuchContactException; +import org.briarproject.bramble.api.db.Transaction; +import org.briarproject.bramble.api.db.TransactionManager; import org.briarproject.bramble.api.identity.IdentityManager; import org.briarproject.bramble.api.identity.LocalAuthor; import org.briarproject.bramble.api.lifecycle.LifecycleManager; @@ -15,6 +17,8 @@ import org.briarproject.bramble.api.sync.GroupId; import org.briarproject.bramble.api.system.Clock; import org.briarproject.briar.android.contactselection.ContactSelectorControllerImpl; import org.briarproject.briar.android.controller.handler.ResultExceptionHandler; +import org.briarproject.briar.api.autodelete.AutoDeleteManager; +import org.briarproject.briar.api.conversation.ConversationManager; import org.briarproject.briar.api.identity.AuthorManager; import org.briarproject.briar.api.privategroup.GroupMessage; import org.briarproject.briar.api.privategroup.GroupMessageFactory; @@ -36,6 +40,8 @@ import javax.inject.Inject; import androidx.annotation.Nullable; import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNonNull; import static org.briarproject.bramble.util.LogUtils.logException; @Immutable @@ -44,9 +50,12 @@ class CreateGroupControllerImpl extends ContactSelectorControllerImpl implements CreateGroupController { private static final Logger LOG = - Logger.getLogger(CreateGroupControllerImpl.class.getName()); + getLogger(CreateGroupControllerImpl.class.getName()); private final Executor cryptoExecutor; + private final TransactionManager db; + private final AutoDeleteManager autoDeleteManager; + private final ConversationManager conversationManager; private final ContactManager contactManager; private final IdentityManager identityManager; private final PrivateGroupFactory groupFactory; @@ -57,17 +66,27 @@ class CreateGroupControllerImpl extends ContactSelectorControllerImpl private final Clock clock; @Inject - CreateGroupControllerImpl(@DatabaseExecutor Executor dbExecutor, + CreateGroupControllerImpl( + @DatabaseExecutor Executor dbExecutor, @CryptoExecutor Executor cryptoExecutor, - LifecycleManager lifecycleManager, ContactManager contactManager, - AuthorManager authorManager, IdentityManager identityManager, + TransactionManager db, + AutoDeleteManager autoDeleteManager, + ConversationManager conversationManager, + LifecycleManager lifecycleManager, + ContactManager contactManager, + AuthorManager authorManager, + IdentityManager identityManager, PrivateGroupFactory groupFactory, GroupMessageFactory groupMessageFactory, PrivateGroupManager groupManager, GroupInvitationFactory groupInvitationFactory, - GroupInvitationManager groupInvitationManager, Clock clock) { + GroupInvitationManager groupInvitationManager, + Clock clock) { super(dbExecutor, lifecycleManager, contactManager, authorManager); this.cryptoExecutor = cryptoExecutor; + this.db = db; + this.autoDeleteManager = autoDeleteManager; + this.conversationManager = conversationManager; this.contactManager = contactManager; this.identityManager = identityManager; this.groupFactory = groupFactory; @@ -131,16 +150,14 @@ class CreateGroupControllerImpl extends ContactSelectorControllerImpl ResultExceptionHandler handler) { runOnDbThread(() -> { try { - LocalAuthor localAuthor = identityManager.getLocalAuthor(); - List contacts = new ArrayList<>(); - for (ContactId c : contactIds) { - try { - contacts.add(contactManager.getContact(c)); - } catch (NoSuchContactException e) { - // Continue - } - } - signInvitations(g, localAuthor, contacts, text, handler); + db.transaction(false, txn -> { + LocalAuthor localAuthor = + identityManager.getLocalAuthor(txn); + List contexts = + createInvitationContexts(txn, contactIds); + txn.attach(() -> signInvitations(g, localAuthor, contexts, + text, handler)); + }); } catch (DbException e) { logException(LOG, WARNING, e); handler.onException(e); @@ -148,17 +165,32 @@ class CreateGroupControllerImpl extends ContactSelectorControllerImpl }); } + private List createInvitationContexts(Transaction txn, + Collection contactIds) throws DbException { + List contexts = new ArrayList<>(); + for (ContactId c : contactIds) { + try { + Contact contact = contactManager.getContact(txn, c); + long timestamp = conversationManager + .getTimestampForOutgoingMessage(txn, c); + long timer = autoDeleteManager.getAutoDeleteTimer(txn, c, + timestamp); + contexts.add(new InvitationContext(contact, timestamp, timer)); + } catch (NoSuchContactException e) { + // Continue + } + } + return contexts; + } + private void signInvitations(GroupId g, LocalAuthor localAuthor, - Collection contacts, @Nullable String text, + List contexts, @Nullable String text, ResultExceptionHandler handler) { cryptoExecutor.execute(() -> { - long timestamp = clock.currentTimeMillis(); - List contexts = new ArrayList<>(); - for (Contact c : contacts) { - byte[] signature = groupInvitationFactory.signInvitation(c, g, - timestamp, localAuthor.getPrivateKey()); - contexts.add(new InvitationContext(c.getId(), timestamp, - signature)); + for (InvitationContext ctx : contexts) { + ctx.signature = groupInvitationFactory.signInvitation( + ctx.contact, g, ctx.timestamp, + localAuthor.getPrivateKey()); } sendInvitations(g, contexts, text, handler); }); @@ -169,11 +201,12 @@ class CreateGroupControllerImpl extends ContactSelectorControllerImpl ResultExceptionHandler handler) { runOnDbThread(() -> { try { - for (InvitationContext context : contexts) { + for (InvitationContext ctx : contexts) { try { groupInvitationManager.sendInvitation(g, - context.contactId, text, context.timestamp, - context.signature); + ctx.contact.getId(), text, ctx.timestamp, + requireNonNull(ctx.signature), + ctx.autoDeleteTimer); } catch (NoSuchContactException e) { // Continue } @@ -188,15 +221,16 @@ class CreateGroupControllerImpl extends ContactSelectorControllerImpl private static class InvitationContext { - private final ContactId contactId; - private final long timestamp; - private final byte[] signature; + private final Contact contact; + private final long timestamp, autoDeleteTimer; + @Nullable + private byte[] signature = null; - private InvitationContext(ContactId contactId, long timestamp, - byte[] signature) { - this.contactId = contactId; + private InvitationContext(Contact contact, long timestamp, + long autoDeleteTimer) { + this.contact = contact; this.timestamp = timestamp; - this.signature = signature; + this.autoDeleteTimer = autoDeleteTimer; } } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java index ed1750c27..66adce301 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/BaseMessageFragment.java @@ -15,6 +15,7 @@ import org.briarproject.briar.android.fragment.BaseFragment; import org.briarproject.briar.android.view.LargeTextInputView; import org.briarproject.briar.android.view.TextSendController; import org.briarproject.briar.android.view.TextSendController.SendListener; +import org.briarproject.briar.android.view.TextSendController.SendState; import org.briarproject.briar.api.attachment.AttachmentHeader; import java.util.List; @@ -22,6 +23,10 @@ import java.util.List; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.annotation.UiThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; @MethodsNotNullByDefault @ParametersNotNullByDefault @@ -79,13 +84,14 @@ public abstract class BaseMessageFragment extends BaseFragment } @Override - public void onSendClick(@Nullable String text, - List headers) { + public LiveData onSendClick(@Nullable String text, + List headers, long expectedAutoDeleteTimer) { // disable button to prevent accidental double actions sendController.setReady(false); message.hideSoftKeyboard(); listener.onButtonClick(text); + return new MutableLiveData<>(SENT); } @UiThread diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogControllerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogControllerImpl.java index abff0f41d..f07cdd130 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogControllerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogControllerImpl.java @@ -10,11 +10,9 @@ import org.briarproject.bramble.api.db.NoSuchGroupException; import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.sync.GroupId; -import org.briarproject.bramble.api.system.Clock; import org.briarproject.briar.android.contactselection.ContactSelectorControllerImpl; import org.briarproject.briar.android.controller.handler.ExceptionHandler; import org.briarproject.briar.api.blog.BlogSharingManager; -import org.briarproject.briar.api.conversation.ConversationManager; import org.briarproject.briar.api.identity.AuthorManager; import java.util.Collection; @@ -26,6 +24,7 @@ import javax.annotation.concurrent.Immutable; import javax.inject.Inject; import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; import static org.briarproject.bramble.util.LogUtils.logException; @Immutable @@ -34,22 +33,17 @@ class ShareBlogControllerImpl extends ContactSelectorControllerImpl implements ShareBlogController { private final static Logger LOG = - Logger.getLogger(ShareBlogControllerImpl.class.getName()); + getLogger(ShareBlogControllerImpl.class.getName()); - private final ConversationManager conversationManager; private final BlogSharingManager blogSharingManager; - private final Clock clock; @Inject ShareBlogControllerImpl(@DatabaseExecutor Executor dbExecutor, LifecycleManager lifecycleManager, ContactManager contactManager, AuthorManager authorManager, - ConversationManager conversationManager, - BlogSharingManager blogSharingManager, Clock clock) { + BlogSharingManager blogSharingManager) { super(dbExecutor, lifecycleManager, contactManager, authorManager); - this.conversationManager = conversationManager; this.blogSharingManager = blogSharingManager; - this.clock = clock; } @Override @@ -64,10 +58,7 @@ class ShareBlogControllerImpl extends ContactSelectorControllerImpl try { for (ContactId c : contacts) { try { - long time = Math.max(clock.currentTimeMillis(), - conversationManager.getGroupCount(c) - .getLatestMsgTime() + 1); - blogSharingManager.sendInvitation(g, c, text, time); + blogSharingManager.sendInvitation(g, c, text); } catch (NoSuchContactException | NoSuchGroupException e) { logException(LOG, WARNING, e); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumControllerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumControllerImpl.java index 1ab210d2e..dcbf5af91 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumControllerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumControllerImpl.java @@ -10,10 +10,8 @@ import org.briarproject.bramble.api.db.NoSuchGroupException; import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.sync.GroupId; -import org.briarproject.bramble.api.system.Clock; import org.briarproject.briar.android.contactselection.ContactSelectorControllerImpl; import org.briarproject.briar.android.controller.handler.ExceptionHandler; -import org.briarproject.briar.api.conversation.ConversationManager; import org.briarproject.briar.api.forum.ForumSharingManager; import org.briarproject.briar.api.identity.AuthorManager; @@ -26,6 +24,7 @@ import javax.annotation.concurrent.Immutable; import javax.inject.Inject; import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; import static org.briarproject.bramble.util.LogUtils.logException; @Immutable @@ -34,22 +33,17 @@ class ShareForumControllerImpl extends ContactSelectorControllerImpl implements ShareForumController { private final static Logger LOG = - Logger.getLogger(ShareForumControllerImpl.class.getName()); + getLogger(ShareForumControllerImpl.class.getName()); - private final ConversationManager conversationManager; private final ForumSharingManager forumSharingManager; - private final Clock clock; @Inject ShareForumControllerImpl(@DatabaseExecutor Executor dbExecutor, LifecycleManager lifecycleManager, ContactManager contactManager, AuthorManager authorManager, - ConversationManager conversationManager, - ForumSharingManager forumSharingManager, Clock clock) { + ForumSharingManager forumSharingManager) { super(dbExecutor, lifecycleManager, contactManager, authorManager); - this.conversationManager = conversationManager; this.forumSharingManager = forumSharingManager; - this.clock = clock; } @Override @@ -64,10 +58,7 @@ class ShareForumControllerImpl extends ContactSelectorControllerImpl try { for (ContactId c : contacts) { try { - long time = Math.max(clock.currentTimeMillis(), - conversationManager.getGroupCount(c) - .getLatestMsgTime() + 1); - forumSharingManager.sendInvitation(g, c, text, time); + forumSharingManager.sendInvitation(g, c, text); } catch (NoSuchContactException | NoSuchGroupException e) { logException(LOG, WARNING, e); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java index ac045316b..40933e20f 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java @@ -19,6 +19,7 @@ import org.briarproject.briar.android.view.BriarRecyclerView; import org.briarproject.briar.android.view.TextInputView; import org.briarproject.briar.android.view.TextSendController; import org.briarproject.briar.android.view.TextSendController.SendListener; +import org.briarproject.briar.android.view.TextSendController.SendState; import org.briarproject.briar.android.view.UnreadMessageButton; import org.briarproject.briar.api.attachment.AttachmentHeader; @@ -29,10 +30,13 @@ import javax.annotation.Nullable; import androidx.annotation.CallSuper; import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import androidx.recyclerview.widget.LinearLayoutManager; import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; @MethodsNotNullByDefault @ParametersNotNullByDefault @@ -231,8 +235,8 @@ public abstract class ThreadListActivity headers) { + public LiveData onSendClick(@Nullable String text, + List headers, long expectedAutoDeleteTimer) { if (isNullOrEmpty(text)) throw new AssertionError(); MessageId replyId = getViewModel().getReplyId(); @@ -241,6 +245,7 @@ public abstract class ThreadListActivity(SENT); } protected abstract int getMaxTextLength(); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java index 50459bb78..5193caa21 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java @@ -6,6 +6,7 @@ import android.app.KeyguardManager; import android.content.Context; import android.content.DialogInterface.OnClickListener; import android.content.Intent; +import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.PowerManager; @@ -75,6 +76,7 @@ import static android.text.format.DateUtils.FORMAT_ABBREV_TIME; import static android.text.format.DateUtils.FORMAT_SHOW_DATE; import static android.text.format.DateUtils.FORMAT_SHOW_TIME; import static android.text.format.DateUtils.FORMAT_SHOW_YEAR; +import static android.text.format.DateUtils.HOUR_IN_MILLIS; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; import static android.text.format.DateUtils.WEEK_IN_MILLIS; import static android.text.format.DateUtils.YEAR_IN_MILLIS; @@ -166,6 +168,36 @@ public class UiUtils { return DateUtils.formatDateTime(ctx, time, flags); } + /** + * Returns the given duration in a human-friendly format. For example, + * "7 days" or "1 hour 3 minutes". + */ + public static String formatDuration(Context ctx, long millis) { + Resources r = ctx.getResources(); + if (millis >= DAY_IN_MILLIS) { + int days = (int) (millis / DAY_IN_MILLIS); + int rest = (int) (millis % DAY_IN_MILLIS); + String dayStr = + r.getQuantityString(R.plurals.duration_days, days, days); + if (rest < HOUR_IN_MILLIS / 2) return dayStr; + else return dayStr + " " + formatDuration(ctx, rest); + } else if (millis >= HOUR_IN_MILLIS) { + int hours = (int) (millis / HOUR_IN_MILLIS); + int rest = (int) (millis % HOUR_IN_MILLIS); + String hourStr = + r.getQuantityString(R.plurals.duration_hours, hours, hours); + if (rest < MINUTE_IN_MILLIS / 2) return hourStr; + else return hourStr + " " + formatDuration(ctx, rest); + } else { + int minutes = + (int) ((millis + MINUTE_IN_MILLIS / 2) / MINUTE_IN_MILLIS); + // anything less than one minute is shown as one minute + if (minutes < 1) minutes = 1; + return r.getQuantityString(R.plurals.duration_minutes, minutes, + minutes); + } + } + public static long getDaysUntilExpiry() { long now = System.currentTimeMillis(); return (EXPIRY_DATE - now) / DAYS.toMillis(1); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/CompositeSendButton.java b/briar-android/src/main/java/org/briarproject/briar/android/view/CompositeSendButton.java index 273a250c5..095130fb7 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/view/CompositeSendButton.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/view/CompositeSendButton.java @@ -5,6 +5,7 @@ import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.widget.FrameLayout; +import android.widget.ImageView; import android.widget.ProgressBar; import org.briarproject.briar.R; @@ -19,6 +20,7 @@ import static java.util.Objects.requireNonNull; public class CompositeSendButton extends FrameLayout { private final AppCompatImageButton sendButton, imageButton; + private final ImageView bombBadge; private final ProgressBar progressBar; private boolean hasImageSupport = false; @@ -32,6 +34,7 @@ public class CompositeSendButton extends FrameLayout { sendButton = findViewById(R.id.sendButton); imageButton = findViewById(R.id.imageButton); + bombBadge = findViewById(R.id.bombBadge); progressBar = findViewById(R.id.progressBar); } @@ -71,6 +74,10 @@ public class CompositeSendButton extends FrameLayout { return hasImageSupport; } + public void setBombVisible(boolean visible) { + bombBadge.setVisibility(visible ? VISIBLE : INVISIBLE); + } + public void showImageButton(boolean showImageButton, boolean sendEnabled) { if (showImageButton) { imageButton.setVisibility(VISIBLE); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java index a048d43e2..9cb3210f3 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java @@ -26,7 +26,6 @@ import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.appcompat.app.AlertDialog.Builder; import androidx.customview.view.AbsSavedState; -import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; @@ -39,6 +38,7 @@ import static androidx.core.content.ContextCompat.getColor; import static androidx.customview.view.AbsSavedState.EMPTY_STATE; import static androidx.lifecycle.Lifecycle.State.DESTROYED; import static org.briarproject.briar.android.util.UiUtils.resolveColorAttribute; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; import static org.briarproject.briar.api.messaging.MessagingConstants.MAX_ATTACHMENTS_PER_MESSAGE; @UiThread @@ -52,7 +52,6 @@ public class TextAttachmentController extends TextSendController private final AttachmentManager attachmentManager; private final List imageUris = new ArrayList<>(); - private final CharSequence textHint; private boolean loadingUris = false; public TextAttachmentController(TextInputView v, ImagePreview imagePreview, @@ -66,23 +65,44 @@ public class TextAttachmentController extends TextSendController sendButton = (CompositeSendButton) compositeSendButton; sendButton.setOnImageClickListener(view -> onImageButtonClicked()); - - textHint = textInput.getHint(); } @Override protected void updateViewState() { - textInput.setEnabled(ready && !loadingUris); - boolean sendEnabled = ready && !loadingUris && - (!textIsEmpty || canSendEmptyText()); + super.updateViewState(); if (loadingUris) { sendButton.showProgress(true); } else if (imageUris.isEmpty()) { sendButton.showProgress(false); - sendButton.showImageButton(textIsEmpty, sendEnabled); + sendButton.showImageButton(textIsEmpty, isSendButtonEnabled()); } else { sendButton.showProgress(false); - sendButton.showImageButton(false, sendEnabled); + sendButton.showImageButton(false, isSendButtonEnabled()); + } + } + + @Override + protected boolean isTextInputEnabled() { + return super.isTextInputEnabled() && !loadingUris; + } + + @Override + protected boolean isSendButtonEnabled() { + return super.isSendButtonEnabled() && !loadingUris; + } + + @Override + protected boolean isBombVisible() { + return super.isBombVisible() && (!textIsEmpty || !imageUris.isEmpty()); + } + + @Override + protected CharSequence getCurrentTextHint() { + if (imageUris.isEmpty()) { + return super.getCurrentTextHint(); + } else { + Context ctx = textInput.getContext(); + return ctx.getString(R.string.image_caption_hint); } } @@ -91,11 +111,17 @@ public class TextAttachmentController extends TextSendController if (canSend()) { if (loadingUris) throw new AssertionError(); listener.onSendClick(textInput.getText(), - attachmentManager.getAttachmentHeadersForSending()); - reset(); + attachmentManager.getAttachmentHeadersForSending(), + expectedTimer).observe(listener, this::onSendStateChanged); } } + @Override + protected void onSendStateChanged(SendState sendState) { + super.onSendStateChanged(sendState); + if (sendState == SENT) reset(); + } + @Override protected boolean canSendEmptyText() { return !imageUris.isEmpty(); @@ -154,6 +180,7 @@ public class TextAttachmentController extends TextSendController private void onNewUris(boolean restart, List newUris) { if (newUris.isEmpty()) return; if (loadingUris) throw new AssertionError(); + if (textIsEmpty) onStartingMessage(); loadingUris = true; if (newUris.size() > MAX_ATTACHMENTS_PER_MESSAGE) { newUris = newUris.subList(0, MAX_ATTACHMENTS_PER_MESSAGE); @@ -161,7 +188,6 @@ public class TextAttachmentController extends TextSendController } imageUris.addAll(newUris); updateViewState(); - textInput.setHint(R.string.image_caption_hint); List items = ImagePreviewItem.fromUris(imageUris); imagePreview.showPreview(items); // store attachments and show preview when successful @@ -207,8 +233,6 @@ public class TextAttachmentController extends TextSendController } private void reset() { - // restore hint - textInput.setHint(textHint); // hide image layout imagePreview.setVisibility(GONE); // reset image URIs @@ -303,7 +327,7 @@ public class TextAttachmentController extends TextSendController } @UiThread - public interface AttachmentListener extends SendListener, LifecycleOwner { + public interface AttachmentListener extends SendListener { void onAttachImage(Intent intent); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java index e070a3dd4..0d6c432c7 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextSendController.java @@ -1,7 +1,9 @@ package org.briarproject.briar.android.view; +import android.content.Context; import android.os.Parcelable; import android.view.View; +import android.widget.Toast; import com.google.android.material.snackbar.Snackbar; @@ -12,11 +14,20 @@ import org.briarproject.briar.api.attachment.AttachmentHeader; import java.util.List; +import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import androidx.annotation.UiThread; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import static android.widget.Toast.LENGTH_LONG; import static com.google.android.material.snackbar.Snackbar.LENGTH_SHORT; import static java.util.Collections.emptyList; +import static org.briarproject.briar.android.view.TextSendController.SendState.ERROR; +import static org.briarproject.briar.android.view.TextSendController.SendState.SENT; +import static org.briarproject.briar.android.view.TextSendController.SendState.UNEXPECTED_TIMER; +import static org.briarproject.briar.api.autodelete.AutoDeleteConstants.NO_AUTO_DELETE_TIMER; @UiThread @NotNullByDefault @@ -26,8 +37,12 @@ public class TextSendController implements TextInputListener { protected final View compositeSendButton; protected final SendListener listener; - protected boolean ready = true, textIsEmpty = true; + protected boolean textIsEmpty = true; + private boolean ready = true; + private long currentTimer = NO_AUTO_DELETE_TIMER; + protected long expectedTimer = NO_AUTO_DELETE_TIMER; + private final CharSequence defaultHint; private final boolean allowEmptyText; public TextSendController(TextInputView v, SendListener listener, @@ -36,31 +51,91 @@ public class TextSendController implements TextInputListener { this.compositeSendButton.setOnClickListener(view -> onSendEvent()); this.listener = listener; this.textInput = v.getEmojiTextInputView(); + this.defaultHint = textInput.getHint(); this.allowEmptyText = allowEmptyText; } @Override public void onTextIsEmptyChanged(boolean isEmpty) { textIsEmpty = isEmpty; + if (!isEmpty) onStartingMessage(); updateViewState(); } @Override public void onSendEvent() { if (canSend()) { - listener.onSendClick(textInput.getText(), emptyList()); + listener.onSendClick(textInput.getText(), emptyList(), + expectedTimer).observe(listener, this::onSendStateChanged); } } + @CallSuper + protected void onSendStateChanged(SendState sendState) { + if (sendState == SENT) { + textInput.clearText(); + } else if (sendState == UNEXPECTED_TIMER) { + boolean enabled = expectedTimer == NO_AUTO_DELETE_TIMER; + showTimerChangedDialog(enabled); + } else if (sendState == ERROR) { + Toast.makeText(textInput.getContext(), R.string.message_error, + LENGTH_LONG).show(); + } + } + + /** + * Call whenever the user starts a new message, + * either by entering text or adding an attachment. + * This updates the expected auto-delete timer to the current value. + */ + protected void onStartingMessage() { + expectedTimer = currentTimer; + } + public void setReady(boolean ready) { this.ready = ready; updateViewState(); } + /** + * Sets the current auto delete timer and updates the UI accordingly. + */ + public void setAutoDeleteTimer(long timer) { + currentTimer = timer; + updateViewState(); + } + + @CallSuper protected void updateViewState() { - textInput.setEnabled(ready); - compositeSendButton - .setEnabled(ready && (!textIsEmpty || canSendEmptyText())); + textInput.setEnabled(isTextInputEnabled()); + textInput.setHint(getCurrentTextHint()); + compositeSendButton.setEnabled(isSendButtonEnabled()); + if (compositeSendButton instanceof CompositeSendButton) { + CompositeSendButton sendButton = + (CompositeSendButton) compositeSendButton; + sendButton.setBombVisible(isBombVisible()); + } + } + + protected boolean isTextInputEnabled() { + return ready; + } + + protected boolean isSendButtonEnabled() { + return ready && (!textIsEmpty || canSendEmptyText()); + } + + protected boolean isBombVisible() { + return currentTimer != NO_AUTO_DELETE_TIMER; + } + + protected CharSequence getCurrentTextHint() { + if (currentTimer == NO_AUTO_DELETE_TIMER) { + return defaultHint; + } else { + Context ctx = textInput.getContext(); + return ctx.getString(R.string.message_hint_auto_delete); + } } protected final boolean canSend() { @@ -76,6 +151,23 @@ public class TextSendController implements TextInputListener { return allowEmptyText; } + private void showTimerChangedDialog(boolean enabled) { + Context ctx = textInput.getContext(); + int message = + enabled ? R.string.auto_delete_changed_warning_message_enabled : + R.string.auto_delete_changed_warning_message_disabled; + new AlertDialog.Builder(ctx, R.style.BriarDialogTheme) + .setTitle(R.string.auto_delete_changed_warning_title) + .setMessage(message) + .setPositiveButton(R.string.auto_delete_changed_warning_send, + (dialog, which) -> { + expectedTimer = currentTimer; + onSendEvent(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + @Nullable public Parcelable onSaveInstanceState(@Nullable Parcelable superState) { return superState; @@ -86,9 +178,11 @@ public class TextSendController implements TextInputListener { return state; } - @UiThread - public interface SendListener { - void onSendClick(@Nullable String text, List headers); + public enum SendState {SENT, ERROR, UNEXPECTED_TIMER} + + public interface SendListener extends LifecycleOwner { + LiveData onSendClick(@Nullable String text, + List headers, long expectedAutoDeleteTimer); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/widget/OnboardingFullDialogFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/widget/OnboardingFullDialogFragment.java new file mode 100644 index 000000000..cf2fd2d72 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/widget/OnboardingFullDialogFragment.java @@ -0,0 +1,66 @@ +package org.briarproject.briar.android.widget; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.briar.R; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; + +@NotNullByDefault +public class OnboardingFullDialogFragment extends DialogFragment { + + public final static String TAG = + OnboardingFullDialogFragment.class.getName(); + + private final static String RES_TITLE = "resTitle"; + private final static String RES_CONTENT = "resContent"; + + public static OnboardingFullDialogFragment newInstance(@StringRes int title, + @StringRes int content) { + Bundle args = new Bundle(); + args.putInt(RES_TITLE, title); + args.putInt(RES_CONTENT, content); + OnboardingFullDialogFragment f = new OnboardingFullDialogFragment(); + f.setArguments(args); + return f; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NORMAL, + R.style.BriarFullScreenDialogTheme); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_onboarding_full, + container, false); + + Bundle args = requireArguments(); + + Toolbar toolbar = view.findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(v -> dismiss()); + toolbar.setTitle(args.getInt(RES_TITLE)); + + TextView contentView = view.findViewById(R.id.contentView); + contentView.setText(args.getInt(RES_CONTENT)); + + view.findViewById(R.id.button).setOnClickListener(v -> dismiss()); + + return view; + } + +} diff --git a/briar-android/src/main/res/drawable/ic_bomb.xml b/briar-android/src/main/res/drawable/ic_bomb.xml new file mode 100644 index 000000000..c874afd02 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_bomb.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/briar-android/src/main/res/layout/fragment_conversation_settings.xml b/briar-android/src/main/res/layout/fragment_conversation_settings.xml new file mode 100644 index 000000000..698840626 --- /dev/null +++ b/briar-android/src/main/res/layout/fragment_conversation_settings.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + +