diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxDirectory.java b/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxDirectory.java new file mode 100644 index 000000000..28151dd15 --- /dev/null +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/mailbox/MailboxDirectory.java @@ -0,0 +1,22 @@ +package org.briarproject.bramble.api.mailbox; + +import java.io.File; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import javax.inject.Qualifier; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation for injecting the {@link File directory} where the Mailbox plugin + * should store its state. + */ +@Qualifier +@Target({FIELD, METHOD, PARAMETER}) +@Retention(RUNTIME) +public @interface MailboxDirectory { +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxFileManager.java b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxFileManager.java new file mode 100644 index 000000000..0320b1bd5 --- /dev/null +++ b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxFileManager.java @@ -0,0 +1,24 @@ +package org.briarproject.bramble.mailbox; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; + +import java.io.File; +import java.io.IOException; + +import javax.annotation.concurrent.ThreadSafe; + +@ThreadSafe +@NotNullByDefault +interface MailboxFileManager { + + /** + * Creates an empty file for storing a download. + */ + File createTempFileForDownload() throws IOException; + + /** + * Handles a file that has been downloaded. The file should be created + * with {@link #createTempFileForDownload()}. + */ + void handleDownloadedFile(File f); +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxFileManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxFileManagerImpl.java new file mode 100644 index 000000000..b3d84837c --- /dev/null +++ b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxFileManagerImpl.java @@ -0,0 +1,174 @@ +package org.briarproject.bramble.mailbox; + +import org.briarproject.bramble.api.connection.ConnectionManager; +import org.briarproject.bramble.api.event.Event; +import org.briarproject.bramble.api.event.EventBus; +import org.briarproject.bramble.api.event.EventListener; +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.mailbox.MailboxDirectory; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.plugin.PluginManager; +import org.briarproject.bramble.api.plugin.TransportConnectionReader; +import org.briarproject.bramble.api.plugin.event.TransportActiveEvent; +import org.briarproject.bramble.api.plugin.simplex.SimplexPlugin; +import org.briarproject.bramble.api.properties.TransportProperties; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.annotation.concurrent.ThreadSafe; +import javax.inject.Inject; + +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RUNNING; +import static org.briarproject.bramble.api.mailbox.MailboxConstants.ID; +import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNonNull; +import static org.briarproject.bramble.api.plugin.file.FileConstants.PROP_PATH; +import static org.briarproject.bramble.util.LogUtils.logException; + +@ThreadSafe +@NotNullByDefault +class MailboxFileManagerImpl implements MailboxFileManager, EventListener { + + private static final Logger LOG = + getLogger(MailboxFileManagerImpl.class.getName()); + + // Package access for testing + static final String DOWNLOAD_DIR_NAME = "downloads"; + + private final Executor ioExecutor; + private final PluginManager pluginManager; + private final ConnectionManager connectionManager; + private final LifecycleManager lifecycleManager; + private final File mailboxDir; + private final EventBus eventBus; + private final CountDownLatch orphanLatch = new CountDownLatch(1); + + @Inject + MailboxFileManagerImpl(@IoExecutor Executor ioExecutor, + PluginManager pluginManager, + ConnectionManager connectionManager, + LifecycleManager lifecycleManager, + @MailboxDirectory File mailboxDir, + EventBus eventBus) { + this.ioExecutor = ioExecutor; + this.pluginManager = pluginManager; + this.connectionManager = connectionManager; + this.lifecycleManager = lifecycleManager; + this.mailboxDir = mailboxDir; + this.eventBus = eventBus; + } + + @Override + public File createTempFileForDownload() throws IOException { + // Wait for orphaned files to be handled before creating new files + try { + orphanLatch.await(); + } catch (InterruptedException e) { + throw new IOException(e); + } + File downloadDir = createDirectoryIfNeeded(DOWNLOAD_DIR_NAME); + return File.createTempFile("mailbox", ".tmp", downloadDir); + } + + private File createDirectoryIfNeeded(String name) throws IOException { + File dir = new File(mailboxDir, name); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + if (!dir.isDirectory()) { + throw new IOException("Failed to create directory '" + name + "'"); + } + return dir; + } + + @Override + public void handleDownloadedFile(File f) { + // We shouldn't reach this point until the plugin has been started + SimplexPlugin plugin = + (SimplexPlugin) requireNonNull(pluginManager.getPlugin(ID)); + TransportProperties p = new TransportProperties(); + p.put(PROP_PATH, f.getAbsolutePath()); + TransportConnectionReader reader = plugin.createReader(p); + if (reader == null) { + LOG.warning("Failed to create reader for downloaded file"); + return; + } + TransportConnectionReader decorated = new MailboxFileReader(reader, f); + LOG.info("Reading downloaded file"); + connectionManager.manageIncomingConnection(ID, decorated, + exception -> isHandlingComplete(exception, true)); + } + + private boolean isHandlingComplete(boolean exception, boolean recognised) { + // If we've successfully read the file then we're done + if (!exception && recognised) return true; + // If the app is shutting down we may get spurious IO exceptions + // due to executors being shut down. Leave the file in the download + // directory and we'll try to read it again at the next startup + return !lifecycleManager.getLifecycleState().isAfter(RUNNING); + } + + @Override + public void eventOccurred(Event e) { + if (e instanceof TransportActiveEvent) { + TransportActiveEvent t = (TransportActiveEvent) e; + if (t.getTransportId().equals(ID)) { + ioExecutor.execute(this::handleOrphanedFiles); + eventBus.removeListener(this); + } + } + } + + /** + * This method is called at startup, as soon as the plugin is started, to + * handle any files that were left in the download directory at the last + * shutdown. + */ + @IoExecutor + private void handleOrphanedFiles() { + try { + File downloadDir = createDirectoryIfNeeded(DOWNLOAD_DIR_NAME); + File[] orphans = downloadDir.listFiles(); + // Now that we've got the list of orphans, new files can be created + orphanLatch.countDown(); + if (orphans != null) for (File f : orphans) handleDownloadedFile(f); + } catch (IOException e) { + logException(LOG, WARNING, e); + } + } + + private class MailboxFileReader implements TransportConnectionReader { + + private final TransportConnectionReader delegate; + private final File file; + + private MailboxFileReader(TransportConnectionReader delegate, + File file) { + this.delegate = delegate; + this.file = file; + } + + @Override + public InputStream getInputStream() throws IOException { + return delegate.getInputStream(); + } + + @Override + public void dispose(boolean exception, boolean recognised) + throws IOException { + delegate.dispose(exception, recognised); + if (isHandlingComplete(exception, recognised)) { + LOG.info("Deleting downloaded file"); + if (!file.delete()) { + LOG.warning("Failed to delete downloaded file"); + } + } + } + } +} diff --git a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxModule.java b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxModule.java index 590a7f857..3a675f774 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxModule.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/mailbox/MailboxModule.java @@ -4,6 +4,7 @@ import org.briarproject.bramble.api.FeatureFlags; import org.briarproject.bramble.api.client.ClientHelper; import org.briarproject.bramble.api.contact.ContactManager; import org.briarproject.bramble.api.data.MetadataEncoder; +import org.briarproject.bramble.api.event.EventBus; import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.mailbox.MailboxManager; import org.briarproject.bramble.api.mailbox.MailboxSettingsManager; @@ -34,6 +35,8 @@ public class MailboxModule { MailboxUpdateValidator mailboxUpdateValidator; @Inject MailboxUpdateManager mailboxUpdateManager; + @Inject + MailboxFileManager mailboxFileManager; } @Provides @@ -101,4 +104,14 @@ public class MailboxModule { } return mailboxUpdateManager; } + + @Provides + @Singleton + MailboxFileManager provideMailboxFileManager(FeatureFlags featureFlags, + EventBus eventBus, MailboxFileManagerImpl mailboxFileManager) { + if (featureFlags.shouldEnableMailbox()) { + eventBus.addListener(mailboxFileManager); + } + return mailboxFileManager; + } } diff --git a/bramble-core/src/test/java/org/briarproject/bramble/mailbox/MailboxFileManagerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/mailbox/MailboxFileManagerImplTest.java new file mode 100644 index 000000000..c67ca2dd4 --- /dev/null +++ b/bramble-core/src/test/java/org/briarproject/bramble/mailbox/MailboxFileManagerImplTest.java @@ -0,0 +1,194 @@ +package org.briarproject.bramble.mailbox; + +import org.briarproject.bramble.api.connection.ConnectionManager; +import org.briarproject.bramble.api.connection.ConnectionManager.TagController; +import org.briarproject.bramble.api.event.EventBus; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState; +import org.briarproject.bramble.api.plugin.PluginManager; +import org.briarproject.bramble.api.plugin.TransportConnectionReader; +import org.briarproject.bramble.api.plugin.event.TransportActiveEvent; +import org.briarproject.bramble.api.plugin.simplex.SimplexPlugin; +import org.briarproject.bramble.api.properties.TransportProperties; +import org.briarproject.bramble.test.BrambleMockTestCase; +import org.briarproject.bramble.test.CaptureArgumentAction; +import org.briarproject.bramble.test.RunAction; +import org.jmock.Expectations; +import org.jmock.lib.action.DoAllAction; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +import static org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RUNNING; +import static org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.STOPPING; +import static org.briarproject.bramble.api.mailbox.MailboxConstants.ID; +import static org.briarproject.bramble.api.plugin.file.FileConstants.PROP_PATH; +import static org.briarproject.bramble.mailbox.MailboxFileManagerImpl.DOWNLOAD_DIR_NAME; +import static org.briarproject.bramble.test.TestUtils.deleteTestDirectory; +import static org.briarproject.bramble.test.TestUtils.getTestDirectory; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MailboxFileManagerImplTest extends BrambleMockTestCase { + + private final Executor ioExecutor = context.mock(Executor.class); + private final PluginManager pluginManager = + context.mock(PluginManager.class); + private final ConnectionManager connectionManager = + context.mock(ConnectionManager.class); + private final LifecycleManager lifecycleManager = + context.mock(LifecycleManager.class); + private final EventBus eventBus = context.mock(EventBus.class); + private final SimplexPlugin plugin = context.mock(SimplexPlugin.class); + private final TransportConnectionReader transportConnectionReader = + context.mock(TransportConnectionReader.class); + + private File mailboxDir; + private MailboxFileManagerImpl manager; + + @Before + public void setUp() { + mailboxDir = getTestDirectory(); + manager = new MailboxFileManagerImpl(ioExecutor, pluginManager, + connectionManager, lifecycleManager, mailboxDir, eventBus); + } + + @After + public void tearDown() { + deleteTestDirectory(mailboxDir); + } + + @Test + public void testHandlesOrphanedFilesAtStartup() throws Exception { + // Create an orphaned file, left behind at the previous shutdown + File downloadDir = new File(mailboxDir, DOWNLOAD_DIR_NAME); + //noinspection ResultOfMethodCallIgnored + downloadDir.mkdirs(); + File orphan = new File(downloadDir, "orphan"); + assertTrue(orphan.createNewFile()); + + TransportProperties props = new TransportProperties(); + props.put(PROP_PATH, orphan.getAbsolutePath()); + + // When the plugin becomes active the orphaned file should be handled + context.checking(new Expectations() {{ + oneOf(ioExecutor).execute(with(any(Runnable.class))); + will(new RunAction()); + oneOf(eventBus).removeListener(manager); + oneOf(pluginManager).getPlugin(ID); + will(returnValue(plugin)); + oneOf(plugin).createReader(props); + will(returnValue(transportConnectionReader)); + oneOf(connectionManager).manageIncomingConnection(with(ID), + with(any(TransportConnectionReader.class)), + with(any(TagController.class))); + }}); + + manager.eventOccurred(new TransportActiveEvent(ID)); + } + + @Test + public void testDeletesFileWhenReadSucceeds() throws Exception { + expectCheckForOrphans(); + manager.eventOccurred(new TransportActiveEvent(ID)); + + File f = manager.createTempFileForDownload(); + AtomicReference reader = + new AtomicReference<>(null); + AtomicReference controller = new AtomicReference<>(null); + + expectPassFileToConnectionManager(f, reader, controller); + manager.handleDownloadedFile(f); + + // The read is successful, so the tag controller should allow the tag + // to be marked as read and the reader should delete the file + context.checking(new Expectations() {{ + oneOf(transportConnectionReader).dispose(false, true); + }}); + + assertTrue(controller.get().shouldMarkTagAsRecognised(false)); + reader.get().dispose(false, true); + assertFalse(f.exists()); + } + + @Test + public void testDeletesFileWhenTagIsNotRecognised() throws Exception { + testDeletesFile(false, RUNNING, false); + } + + @Test + public void testDeletesFileWhenReadFails() throws Exception { + testDeletesFile(true, RUNNING, false); + } + + @Test + public void testDoesNotDeleteFileWhenTagIsNotRecognisedAtShutdown() + throws Exception { + testDeletesFile(false, STOPPING, true); + } + + @Test + public void testDoesNotDeleteFileWhenReadFailsAtShutdown() + throws Exception { + testDeletesFile(true, STOPPING, true); + } + + private void testDeletesFile(boolean recognised, LifecycleState state, + boolean fileExists) throws Exception { + expectCheckForOrphans(); + manager.eventOccurred(new TransportActiveEvent(ID)); + + File f = manager.createTempFileForDownload(); + AtomicReference reader = + new AtomicReference<>(null); + AtomicReference controller = new AtomicReference<>(null); + + expectPassFileToConnectionManager(f, reader, controller); + manager.handleDownloadedFile(f); + + context.checking(new Expectations() {{ + oneOf(transportConnectionReader).dispose(true, recognised); + oneOf(lifecycleManager).getLifecycleState(); + will(returnValue(state)); + }}); + + reader.get().dispose(true, recognised); + assertEquals(fileExists, f.exists()); + } + + private void expectCheckForOrphans() { + context.checking(new Expectations() {{ + oneOf(ioExecutor).execute(with(any(Runnable.class))); + will(new RunAction()); + oneOf(eventBus).removeListener(manager); + }}); + } + + private void expectPassFileToConnectionManager(File f, + AtomicReference reader, + AtomicReference controller) { + TransportProperties props = new TransportProperties(); + props.put(PROP_PATH, f.getAbsolutePath()); + + context.checking(new Expectations() {{ + oneOf(pluginManager).getPlugin(ID); + will(returnValue(plugin)); + oneOf(plugin).createReader(props); + will(returnValue(transportConnectionReader)); + oneOf(connectionManager).manageIncomingConnection(with(ID), + with(any(TransportConnectionReader.class)), + with(any(TagController.class))); + will(new DoAllAction( + new CaptureArgumentAction<>(reader, + TransportConnectionReader.class, 1), + new CaptureArgumentAction<>(controller, + TagController.class, 2) + )); + }}); + } +} diff --git a/bramble-core/src/test/java/org/briarproject/bramble/plugin/file/RemovableDriveIntegrationTestComponent.java b/bramble-core/src/test/java/org/briarproject/bramble/plugin/file/RemovableDriveIntegrationTestComponent.java index 385e140da..32c0471aa 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/plugin/file/RemovableDriveIntegrationTestComponent.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/plugin/file/RemovableDriveIntegrationTestComponent.java @@ -13,6 +13,7 @@ import org.briarproject.bramble.system.DefaultWakefulIoExecutorModule; import org.briarproject.bramble.system.TimeTravelModule; import org.briarproject.bramble.test.TestDatabaseConfigModule; import org.briarproject.bramble.test.TestFeatureFlagModule; +import org.briarproject.bramble.test.TestMailboxDirectoryModule; import org.briarproject.bramble.test.TestSecureRandomModule; import javax.inject.Singleton; @@ -27,6 +28,7 @@ import dagger.Component; DefaultWakefulIoExecutorModule.class, TestDatabaseConfigModule.class, TestFeatureFlagModule.class, + TestMailboxDirectoryModule.class, RemovableDriveIntegrationTestModule.class, RemovableDriveModule.class, TestSecureRandomModule.class, 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 b735dab7d..6a2055e7e 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 @@ -13,6 +13,7 @@ import dagger.Module; DefaultWakefulIoExecutorModule.class, TestDatabaseConfigModule.class, TestFeatureFlagModule.class, + TestMailboxDirectoryModule.class, TestPluginConfigModule.class, TestSecureRandomModule.class, TimeTravelModule.class diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/TestMailboxDirectoryModule.java b/bramble-core/src/test/java/org/briarproject/bramble/test/TestMailboxDirectoryModule.java new file mode 100644 index 000000000..71c5ff58a --- /dev/null +++ b/bramble-core/src/test/java/org/briarproject/bramble/test/TestMailboxDirectoryModule.java @@ -0,0 +1,18 @@ +package org.briarproject.bramble.test; + +import org.briarproject.bramble.api.mailbox.MailboxDirectory; + +import java.io.File; + +import dagger.Module; +import dagger.Provides; + +@Module +public class TestMailboxDirectoryModule { + + @Provides + @MailboxDirectory + File provideMailboxDirectory() { + return new File("mailbox"); + } +} 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 e661cc290..55ebd6234 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 @@ -14,6 +14,7 @@ import org.briarproject.bramble.api.crypto.PublicKey; import org.briarproject.bramble.api.db.DatabaseConfig; import org.briarproject.bramble.api.event.EventBus; import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.mailbox.MailboxDirectory; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.plugin.BluetoothConstants; import org.briarproject.bramble.api.plugin.LanTcpConstants; @@ -157,6 +158,13 @@ public class AppModule { return new AndroidDatabaseConfig(dbDir, keyDir, keyStrengthener); } + @Provides + @Singleton + @MailboxDirectory + File provideMailboxDirectory(Application app) { + return app.getDir("mailbox", MODE_PRIVATE); + } + @Provides @Singleton @TorDirectory diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt index 85cf884d3..e78774011 100644 --- a/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt +++ b/briar-headless/src/main/java/org/briarproject/briar/headless/HeadlessModule.kt @@ -6,6 +6,7 @@ import dagger.Provides import org.briarproject.bramble.account.AccountModule import org.briarproject.bramble.api.FeatureFlags import org.briarproject.bramble.api.db.DatabaseConfig +import org.briarproject.bramble.api.mailbox.MailboxDirectory import org.briarproject.bramble.api.plugin.PluginConfig import org.briarproject.bramble.api.plugin.TorConstants.DEFAULT_CONTROL_PORT import org.briarproject.bramble.api.plugin.TorConstants.DEFAULT_SOCKS_PORT @@ -71,6 +72,12 @@ internal class HeadlessModule(private val appDir: File) { return HeadlessDatabaseConfig(dbDir, keyDir) } + @Provides + @MailboxDirectory + internal fun provideMailboxDirectory(): File { + return File(appDir, "mailbox") + } + @Provides @TorDirectory internal fun provideTorDirectory(): File { diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/HeadlessTestModule.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/HeadlessTestModule.kt index b2be6db86..3c0da66aa 100644 --- a/briar-headless/src/test/java/org/briarproject/briar/headless/HeadlessTestModule.kt +++ b/briar-headless/src/test/java/org/briarproject/briar/headless/HeadlessTestModule.kt @@ -5,6 +5,7 @@ import dagger.Module import dagger.Provides import org.briarproject.bramble.account.AccountModule import org.briarproject.bramble.api.db.DatabaseConfig +import org.briarproject.bramble.api.mailbox.MailboxDirectory import org.briarproject.bramble.api.plugin.PluginConfig import org.briarproject.bramble.api.plugin.TorConstants.DEFAULT_CONTROL_PORT import org.briarproject.bramble.api.plugin.TorConstants.DEFAULT_SOCKS_PORT @@ -68,6 +69,12 @@ internal class HeadlessTestModule(private val appDir: File) { return HeadlessDatabaseConfig(dbDir, keyDir) } + @Provides + @MailboxDirectory + internal fun provideMailboxDirectory(): File { + return File(appDir, "mailbox") + } + @Provides @TorSocksPort internal fun provideTorSocksPort(): Int = DEFAULT_SOCKS_PORT