diff --git a/build.gradle b/build.gradle
index dd593196c4c1636d3f68bec6bdf809ea7979fd89..5dbb31c8e66191bebfac5b0d44c3718d24a7a242 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,6 @@
 buildscript {
     ext.kotlin_version = '1.5.21'
-    ext.hilt_version = '2.37'
+    ext.hilt_version = '2.38.1'
     ext.junit_version = '5.7.2'
     ext.mockk_version = '1.10.4'
     repositories {
diff --git a/mailbox-android/src/main/AndroidManifest.xml b/mailbox-android/src/main/AndroidManifest.xml
index 7ecc940b5595f8575158169b7016c59617df104d..aa9cb5779b2173a559165fbe0a1249064042fdfe 100644
--- a/mailbox-android/src/main/AndroidManifest.xml
+++ b/mailbox-android/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
 
     <application
         android:name=".android.MailboxApplication"
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b8a6bd90022d3a2f2df6cbc383c9eb3cfe9d6b5f
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
@@ -0,0 +1,14 @@
+package org.briarproject.mailbox.android
+
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.CoreModule
+
+@Module(
+    includes = [
+        CoreModule::class,
+    ]
+)
+@InstallIn(SingletonComponent::class)
+internal class AppModule
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt
index 72dab53170f5a22137ce276a388690307bab04b3..c5689ed260bf21589ea5500521cb2000633035f4 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/ApplicationComponent.kt
@@ -2,7 +2,11 @@ package org.briarproject.mailbox.android
 
 import dagger.Component
 
-@Component
+@Component(
+    modules = [
+        AppModule::class,
+    ]
+)
 interface ApplicationComponent {
 
     fun inject(activity: MainActivity)
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt
index ba58719345388507e89498d934089a8d94aff303..939a5acf167312898f38f00e5f61d7f5d4522eeb 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxApplication.kt
@@ -2,10 +2,15 @@ package org.briarproject.mailbox.android
 
 import androidx.multidex.MultiDexApplication
 import dagger.hilt.android.HiltAndroidApp
+import org.briarproject.mailbox.core.CoreEagerSingletons
+import javax.inject.Inject
 
 @HiltAndroidApp
 class MailboxApplication : MultiDexApplication() {
 
+    @Inject
+    lateinit var coreEagerSingletons: CoreEagerSingletons
+
     override fun onCreate() {
         super.onCreate()
         MailboxService.startService(this)
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt
index b3b91e01d637509f8eb17ff267eca064b286ebed..4461d87f8751fc39013749ab88dcdf8282a379a9 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt
@@ -7,7 +7,13 @@ import android.os.IBinder
 import androidx.core.content.ContextCompat
 import dagger.hilt.android.AndroidEntryPoint
 import org.briarproject.mailbox.android.MailboxNotificationManager.Companion.NOTIFICATION_MAIN_ID
-import org.briarproject.mailbox.core.server.WebServerManager
+import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.StartResult
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.StartResult.ALREADY_RUNNING
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.StartResult.SUCCESS
+import java.util.logging.Level
+import java.util.logging.Logger
 import javax.inject.Inject
 
 @AndroidEntryPoint
@@ -25,17 +31,43 @@ class MailboxService : Service() {
         }
     }
 
+    private val LOG = Logger.getLogger(MailboxService::class.java.name)
+
+    @Volatile
+    internal var started = false
+
     @Inject
-    internal lateinit var notificationManager: MailboxNotificationManager
+    internal lateinit var wakeLockManager: AndroidWakeLockManager
 
     @Inject
-    internal lateinit var webServerManager: WebServerManager
+    internal lateinit var lifecycleManager: LifecycleManager
+
+    @Inject
+    internal lateinit var notificationManager: MailboxNotificationManager
+
+    override fun onCreate() {
+        super.onCreate()
 
-    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
-        startForeground(NOTIFICATION_MAIN_ID, notificationManager.serviceNotification)
-        // TODO handle inside LifecycleManager
-        webServerManager.start()
-        return START_NOT_STICKY
+        // Hold a wake lock during startup
+        wakeLockManager.runWakefully({
+            startForeground(NOTIFICATION_MAIN_ID, notificationManager.serviceNotification)
+            // Start the services in a background thread
+            wakeLockManager.executeWakefully({
+                val result: StartResult = lifecycleManager.startServices()
+                if (result === SUCCESS) {
+                    started = true
+                } else if (result === ALREADY_RUNNING) {
+                    LOG.info("Already running")
+                    stopSelf()
+                } else {
+                    if (LOG.isLoggable(Level.WARNING))
+                        LOG.warning("Startup failed: $result")
+                    // TODO: implement this
+                    // showStartupFailure(result)
+                    stopSelf()
+                }
+            }, "LifecycleStartup")
+        }, "LifecycleStartup")
     }
 
     override fun onBind(intent: Intent): IBinder? {
@@ -43,8 +75,12 @@ class MailboxService : Service() {
     }
 
     override fun onDestroy() {
-        // TODO handle inside LifecycleManager
-        webServerManager.stop()
-        super.onDestroy()
+        wakeLockManager.runWakefully({
+            super.onDestroy()
+            wakeLockManager.executeWakefully(
+                { lifecycleManager.stopServices() },
+                "LifecycleShutdown"
+            )
+        }, "LifecycleShutdown")
     }
 }
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidExecutor.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidExecutor.java
new file mode 100644
index 0000000000000000000000000000000000000000..8e9ea64c11cd4d392fae23ef3a04faa259938ffb
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidExecutor.java
@@ -0,0 +1,33 @@
+package org.briarproject.mailbox.android.api.system;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+/**
+ * Enables background threads to make Android API calls that must be made from
+ * a thread with a message queue.
+ */
+public interface AndroidExecutor {
+
+    /**
+     * Runs the given task on a background thread with a message queue and
+     * returns a Future for getting the result.
+     */
+    <V> Future<V> runOnBackgroundThread(Callable<V> c);
+
+    /**
+     * Runs the given task on a background thread with a message queue.
+     */
+    void runOnBackgroundThread(Runnable r);
+
+    /**
+     * Runs the given task on the main UI thread and returns a Future for
+     * getting the result.
+     */
+    <V> Future<V> runOnUiThread(Callable<V> c);
+
+    /**
+     * Runs the given task on the main UI thread.
+     */
+    void runOnUiThread(Runnable r);
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLock.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLock.java
new file mode 100644
index 0000000000000000000000000000000000000000..f1577efd080a858ecadfbe4cd8d8416a9c793e2d
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLock.java
@@ -0,0 +1,16 @@
+package org.briarproject.mailbox.android.api.system;
+
+public interface AndroidWakeLock {
+
+    /**
+     * Acquires the wake lock. This has no effect if the wake lock has already
+     * been acquired.
+     */
+    void acquire();
+
+    /**
+     * Releases the wake lock. This has no effect if the wake lock has already
+     * been released.
+     */
+    void release();
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLockManager.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLockManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..917d10b191d21421151d2d98b300b7c1408e4bc6
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLockManager.java
@@ -0,0 +1,35 @@
+package org.briarproject.mailbox.android.api.system;
+
+import java.util.concurrent.Executor;
+
+public interface AndroidWakeLockManager {
+
+    /**
+     * Creates a wake lock with the given tag. The tag is only used for
+     * logging; the underlying OS wake lock will use its own tag.
+     */
+    AndroidWakeLock createWakeLock(String tag);
+
+    /**
+     * Runs the given task while holding a wake lock.
+     */
+    void runWakefully(Runnable r, String tag);
+
+    /**
+     * Submits the given task to the given executor while holding a wake lock.
+     * The lock is released when the task completes, or if an exception is
+     * thrown while submitting or running the task.
+     */
+    void executeWakefully(Runnable r, Executor executor, String tag);
+
+    /**
+     * Starts a dedicated thread to run the given task asynchronously. A wake
+     * lock is acquired before starting the thread and released when the task
+     * completes, or if an exception is thrown while starting the thread or
+     * running the task.
+     * <p>
+     * This method should only be used for lifecycle management tasks that
+     * can't be run on an executor.
+     */
+    void executeWakefully(Runnable r, String tag);
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidExecutorImpl.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidExecutorImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..14a66f08868d961b21aaea616b66bc1a3a2832ad
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidExecutorImpl.java
@@ -0,0 +1,75 @@
+package org.briarproject.mailbox.android.system;
+
+import android.app.Application;
+import android.os.Handler;
+import android.os.Looper;
+
+import org.briarproject.mailbox.android.api.system.AndroidExecutor;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.inject.Inject;
+
+class AndroidExecutorImpl implements AndroidExecutor {
+
+    private final Handler uiHandler;
+    private final Runnable loop;
+    private final AtomicBoolean started = new AtomicBoolean(false);
+    private final CountDownLatch startLatch = new CountDownLatch(1);
+
+    private volatile Handler backgroundHandler = null;
+
+    @Inject
+    AndroidExecutorImpl(Application app) {
+        uiHandler = new Handler(app.getApplicationContext().getMainLooper());
+        loop = () -> {
+            Looper.prepare();
+            backgroundHandler = new Handler();
+            startLatch.countDown();
+            Looper.loop();
+        };
+    }
+
+    private void startIfNecessary() {
+        if (!started.getAndSet(true)) {
+            Thread t = new Thread(loop, "AndroidExecutor");
+            t.setDaemon(true);
+            t.start();
+        }
+        try {
+            startLatch.await();
+        } catch (InterruptedException e) {
+            throw new RejectedExecutionException(e);
+        }
+    }
+
+    @Override
+    public <V> Future<V> runOnBackgroundThread(Callable<V> c) {
+        FutureTask<V> f = new FutureTask<>(c);
+        runOnBackgroundThread(f);
+        return f;
+    }
+
+    @Override
+    public void runOnBackgroundThread(Runnable r) {
+        startIfNecessary();
+        backgroundHandler.post(r);
+    }
+
+    @Override
+    public <V> Future<V> runOnUiThread(Callable<V> c) {
+        FutureTask<V> f = new FutureTask<>(c);
+        runOnUiThread(f);
+        return f;
+    }
+
+    @Override
+    public void runOnUiThread(Runnable r) {
+        uiHandler.post(r);
+    }
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidSystemModule.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidSystemModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..917feb9037102c346ac4dd0df7c4ee1ecd72da7f
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidSystemModule.kt
@@ -0,0 +1,60 @@
+package org.briarproject.mailbox.android.system
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.android.api.system.AndroidExecutor
+import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import java.util.concurrent.Executor
+import java.util.concurrent.RejectedExecutionHandler
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.ScheduledThreadPoolExecutor
+import java.util.concurrent.ThreadPoolExecutor
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class AndroidSystemModule {
+
+    private var scheduledExecutorService: ScheduledExecutorService
+
+    init {
+        // Discard tasks that are submitted during shutdown
+        val policy: RejectedExecutionHandler = ThreadPoolExecutor.DiscardPolicy()
+        scheduledExecutorService = ScheduledThreadPoolExecutor(1, policy)
+    }
+
+    @Provides
+    @Singleton
+    fun provideScheduledExecutorService(
+        lifecycleManager: LifecycleManager,
+    ): ScheduledExecutorService {
+        lifecycleManager.registerForShutdown(scheduledExecutorService)
+        return scheduledExecutorService
+    }
+
+    @Provides
+    @Singleton
+    fun provideWakeLockManager(
+        wakeLockManager: AndroidWakeLockManagerImpl,
+    ): AndroidWakeLockManager {
+        return wakeLockManager
+    }
+
+    @Provides
+    @Singleton
+    fun provideAndroidExecutor(androidExecutor: AndroidExecutorImpl): AndroidExecutor {
+        return androidExecutor
+    }
+
+    @Provides
+    @Singleton
+    fun provideEventExecutor(androidExecutor: AndroidExecutor): Executor {
+        return Executor {
+            androidExecutor.runOnUiThread(it)
+        }
+    }
+
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockImpl.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..7d6ac25c0fdd04abd6042995c65d37a2a2be5c4f
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockImpl.java
@@ -0,0 +1,72 @@
+package org.briarproject.mailbox.android.system;
+
+import static java.util.logging.Level.FINE;
+import static java.util.logging.Logger.getLogger;
+
+import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Logger;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A wrapper around a {@link SharedWakeLock} that provides the more convenient
+ * semantics of {@link AndroidWakeLock} (i.e. calls to acquire() and release()
+ * don't need to be balanced).
+ */
+@ThreadSafe
+class AndroidWakeLockImpl implements AndroidWakeLock {
+
+    private static final Logger LOG =
+            getLogger(AndroidWakeLockImpl.class.getName());
+
+    private static final AtomicInteger INSTANCE_ID = new AtomicInteger(0);
+
+    private final SharedWakeLock sharedWakeLock;
+    private final String tag;
+
+    private final Object lock = new Object();
+    @GuardedBy("lock")
+    private boolean held = false;
+
+    AndroidWakeLockImpl(SharedWakeLock sharedWakeLock, String tag) {
+        this.sharedWakeLock = sharedWakeLock;
+        this.tag = tag + "_" + INSTANCE_ID.getAndIncrement();
+    }
+
+    @Override
+    public void acquire() {
+        synchronized (lock) {
+            if (held) {
+                if (LOG.isLoggable(FINE)) {
+                    LOG.fine(tag + " already acquired");
+                }
+            } else {
+                if (LOG.isLoggable(FINE)) {
+                    LOG.fine(tag + " acquiring shared wake lock");
+                }
+                held = true;
+                sharedWakeLock.acquire();
+            }
+        }
+    }
+
+    @Override
+    public void release() {
+        synchronized (lock) {
+            if (held) {
+                if (LOG.isLoggable(FINE)) {
+                    LOG.fine(tag + " releasing shared wake lock");
+                }
+                held = false;
+                sharedWakeLock.release();
+            } else {
+                if (LOG.isLoggable(FINE)) {
+                    LOG.fine(tag + " already released");
+                }
+            }
+        }
+    }
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockManagerImpl.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockManagerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..bb590249d6affd5abebd7404e70a63f600e300c2
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockManagerImpl.java
@@ -0,0 +1,123 @@
+package org.briarproject.mailbox.android.system;
+
+import static android.content.Context.POWER_SERVICE;
+import static android.os.PowerManager.PARTIAL_WAKE_LOCK;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.PowerManager;
+
+import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
+import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+
+import javax.inject.Inject;
+
+class AndroidWakeLockManagerImpl implements AndroidWakeLockManager {
+
+    /**
+     * How often to replace the wake lock.
+     */
+    private static final long LOCK_DURATION_MS = MINUTES.toMillis(1);
+
+    /**
+     * Automatically release the lock this many milliseconds after it's due
+     * to have been replaced and released.
+     */
+    private static final long SAFETY_MARGIN_MS = SECONDS.toMillis(30);
+
+    private final SharedWakeLock sharedWakeLock;
+
+    @Inject
+    AndroidWakeLockManagerImpl(Application app,
+                               ScheduledExecutorService scheduledExecutorService) {
+        PowerManager powerManager = (PowerManager)
+                requireNonNull(app.getSystemService(POWER_SERVICE));
+        String tag = getWakeLockTag(app);
+        sharedWakeLock = new RenewableWakeLock(powerManager,
+                scheduledExecutorService, PARTIAL_WAKE_LOCK, tag,
+                LOCK_DURATION_MS, SAFETY_MARGIN_MS);
+    }
+
+    @Override
+    public AndroidWakeLock createWakeLock(String tag) {
+        return new AndroidWakeLockImpl(sharedWakeLock, tag);
+    }
+
+    @Override
+    public void runWakefully(Runnable r, String tag) {
+        AndroidWakeLock wakeLock = createWakeLock(tag);
+        wakeLock.acquire();
+        try {
+            r.run();
+        } finally {
+            wakeLock.release();
+        }
+    }
+
+    @Override
+    public void executeWakefully(Runnable r, Executor executor, String tag) {
+        AndroidWakeLock wakeLock = createWakeLock(tag);
+        wakeLock.acquire();
+        try {
+            executor.execute(() -> {
+                try {
+                    r.run();
+                } finally {
+                    // Release the wake lock if the task throws an exception
+                    wakeLock.release();
+                }
+            });
+        } catch (Exception e) {
+            // Release the wake lock if the executor throws an exception when
+            // we submit the task (in which case the release() call above won't
+            // happen)
+            wakeLock.release();
+            throw e;
+        }
+    }
+
+    @Override
+    public void executeWakefully(Runnable r, String tag) {
+        AndroidWakeLock wakeLock = createWakeLock(tag);
+        wakeLock.acquire();
+        try {
+            new Thread(() -> {
+                try {
+                    r.run();
+                } finally {
+                    wakeLock.release();
+                }
+            }).start();
+        } catch (Exception e) {
+            wakeLock.release();
+            throw e;
+        }
+    }
+
+    private String getWakeLockTag(Context ctx) {
+        PackageManager pm = ctx.getPackageManager();
+        if (isInstalled(pm, "com.huawei.powergenie")) {
+            return "LocationManagerService";
+        } else if (isInstalled(pm, "com.evenwell.PowerMonitor")) {
+            return "AudioIn";
+        }
+        return ctx.getPackageName();
+    }
+
+    private boolean isInstalled(PackageManager pm, String packageName) {
+        try {
+            pm.getPackageInfo(packageName, 0);
+            return true;
+        } catch (PackageManager.NameNotFoundException e) {
+            return false;
+        }
+    }
+
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/RenewableWakeLock.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/RenewableWakeLock.java
new file mode 100644
index 0000000000000000000000000000000000000000..d4a910cc144bb899053608210e91f6417995bbf4
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/RenewableWakeLock.java
@@ -0,0 +1,127 @@
+package org.briarproject.mailbox.android.system;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.logging.Level.FINE;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
+
+import android.os.PowerManager;
+import android.os.PowerManager.WakeLock;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+@ThreadSafe
+class RenewableWakeLock implements SharedWakeLock {
+
+    private static final Logger LOG =
+            getLogger(RenewableWakeLock.class.getName());
+
+    private final PowerManager powerManager;
+    private final ScheduledExecutorService scheduledExecutorService;
+    private final int levelAndFlags;
+    private final String tag;
+    private final long durationMs, safetyMarginMs;
+
+    private final Object lock = new Object();
+    @GuardedBy("lock")
+    @Nullable
+    private WakeLock wakeLock;
+    @GuardedBy("lock")
+    @Nullable
+    private Future<?> future;
+    @GuardedBy("lock")
+    private int refCount = 0;
+    @GuardedBy("lock")
+    private long acquired = 0;
+
+    RenewableWakeLock(PowerManager powerManager,
+                      ScheduledExecutorService scheduledExecutorService,
+                      int levelAndFlags,
+                      String tag,
+                      long durationMs,
+                      long safetyMarginMs) {
+        this.powerManager = powerManager;
+        this.scheduledExecutorService = scheduledExecutorService;
+        this.levelAndFlags = levelAndFlags;
+        this.tag = tag;
+        this.durationMs = durationMs;
+        this.safetyMarginMs = safetyMarginMs;
+    }
+
+    @Override
+    public void acquire() {
+        synchronized (lock) {
+            refCount++;
+            if (refCount == 1) {
+                if (LOG.isLoggable(INFO)) {
+                    LOG.info("Acquiring wake lock " + tag);
+                }
+                wakeLock = powerManager.newWakeLock(levelAndFlags, tag);
+                // We do our own reference counting so we can replace the lock
+                // TODO: Check whether using a ref-counted wake lock affects
+                //  power management apps
+                wakeLock.setReferenceCounted(false);
+                wakeLock.acquire(durationMs + safetyMarginMs);
+                future = scheduledExecutorService.schedule(this::renew,
+                        durationMs, MILLISECONDS);
+                acquired = android.os.SystemClock.elapsedRealtime();
+            } else if (LOG.isLoggable(FINE)) {
+                LOG.fine("Wake lock " + tag + " has " + refCount + " holders");
+            }
+        }
+    }
+
+    private void renew() {
+        if (LOG.isLoggable(INFO)) LOG.info("Renewing wake lock " + tag);
+        synchronized (lock) {
+            if (wakeLock == null) {
+                LOG.info("Already released");
+                return;
+            }
+            if (LOG.isLoggable(FINE)) {
+                LOG.fine("Wake lock " + tag + " has " + refCount + " holders");
+            }
+            long now = android.os.SystemClock.elapsedRealtime();
+            long expiry = acquired + durationMs + safetyMarginMs;
+            if (now > expiry && LOG.isLoggable(WARNING)) {
+                LOG.warning("Wake lock expired " + (now - expiry) + " ms ago");
+            }
+            WakeLock oldWakeLock = wakeLock;
+            wakeLock = powerManager.newWakeLock(levelAndFlags, tag);
+            wakeLock.setReferenceCounted(false);
+            wakeLock.acquire(durationMs + safetyMarginMs);
+            oldWakeLock.release();
+            future = scheduledExecutorService.schedule(this::renew, durationMs,
+                    MILLISECONDS);
+            acquired = now;
+        }
+    }
+
+    @Override
+    public void release() {
+        synchronized (lock) {
+            refCount--;
+            if (refCount == 0) {
+                if (LOG.isLoggable(INFO)) {
+                    LOG.info("Releasing wake lock " + tag);
+                }
+                requireNonNull(future).cancel(false);
+                future = null;
+                requireNonNull(wakeLock).release();
+                wakeLock = null;
+                acquired = 0;
+            } else if (LOG.isLoggable(FINE)) {
+                LOG.fine("Wake lock " + tag + " has " + refCount + " holders");
+            }
+        }
+    }
+}
+
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/SharedWakeLock.java b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/SharedWakeLock.java
new file mode 100644
index 0000000000000000000000000000000000000000..a4e353de45c2e690a75299f698b3371968ff2f9f
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/SharedWakeLock.java
@@ -0,0 +1,21 @@
+package org.briarproject.mailbox.android.system;
+
+import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
+
+interface SharedWakeLock {
+
+    /**
+     * Acquires the wake lock. This increments the wake lock's reference count,
+     * so unlike {@link AndroidWakeLock#acquire()} every call to this method
+     * must be followed by a balancing call to {@link #release()}.
+     */
+    void acquire();
+
+    /**
+     * Releases the wake lock. This decrements the wake lock's reference count,
+     * so unlike {@link AndroidWakeLock#release()} every call to this method
+     * must follow a balancing call to {@link #acquire()}.
+     */
+    void release();
+
+}
diff --git a/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 7353dbd1fd82487df2d06121f85f7994728f1070..ac94b34f54e396eb7aa0f59dc834ac07f4741db5 100644
--- a/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
-    <background android:drawable="@color/ic_launcher_background"/>
-    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
 </adaptive-icon>
\ No newline at end of file
diff --git a/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 7353dbd1fd82487df2d06121f85f7994728f1070..ac94b34f54e396eb7aa0f59dc834ac07f4741db5 100644
--- a/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/mailbox-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
-    <background android:drawable="@color/ic_launcher_background"/>
-    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
 </adaptive-icon>
\ No newline at end of file
diff --git a/mailbox-android/src/main/res/values-night/themes.xml b/mailbox-android/src/main/res/values-night/themes.xml
index f40cdc54bcc254a36b6ca9608df6e9b741cd8ec9..8b6f2d272f57a825e5e3f2bbf67c1443a2df7b40 100644
--- a/mailbox-android/src/main/res/values-night/themes.xml
+++ b/mailbox-android/src/main/res/values-night/themes.xml
@@ -1,4 +1,4 @@
-<resources xmlns:tools="http://schemas.android.com/tools">
+<resources>
     <!-- Base application theme. -->
     <style name="Theme.Briarmailbox" parent="Theme.AppCompat.Light.DarkActionBar">
         <!-- Primary brand color. -->
diff --git a/mailbox-android/src/main/res/values/themes.xml b/mailbox-android/src/main/res/values/themes.xml
index d31cb1eff1af1a7a05d903c00d9c546f44a62f69..9d0dfedbc6008d4403b648f4fb6c3393cba66d18 100644
--- a/mailbox-android/src/main/res/values/themes.xml
+++ b/mailbox-android/src/main/res/values/themes.xml
@@ -1,4 +1,4 @@
-<resources xmlns:tools="http://schemas.android.com/tools">
+<resources>
     <!-- Base application theme. -->
     <style name="Theme.Briarmailbox" parent="Theme.AppCompat.Light.DarkActionBar">
         <!-- Primary brand color. -->
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
index ab13693777396c584feda930da7f410bb4cda41e..d8eec8e63a3d539b4824ec46ada496e94acd274f 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/Main.kt
@@ -2,20 +2,13 @@ package org.briarproject.mailbox.cli
 
 import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.options.counted
-import com.github.ajalt.clikt.parameters.options.default
 import com.github.ajalt.clikt.parameters.options.flag
 import com.github.ajalt.clikt.parameters.options.option
-import com.github.ajalt.clikt.parameters.types.int
 import org.slf4j.impl.SimpleLogger.DEFAULT_LOG_LEVEL_KEY
-import java.io.File
-import java.io.File.separator
-import java.io.IOException
-import java.lang.System.getProperty
 import java.lang.System.setProperty
-import java.nio.file.Files.setPosixFilePermissions
-import java.nio.file.attribute.PosixFilePermission
-import java.nio.file.attribute.PosixFilePermission.*
-import java.util.logging.Level.*
+import java.util.logging.Level.ALL
+import java.util.logging.Level.INFO
+import java.util.logging.Level.WARNING
 import java.util.logging.LogManager
 
 private class Main : CliktCommand(
diff --git a/mailbox-core/build.gradle b/mailbox-core/build.gradle
index b61ec076352d3231c5794d2b3cbbba2c1ec15d46..82bc45a5ca36a534ccd29e24542f97f306d71895 100644
--- a/mailbox-core/build.gradle
+++ b/mailbox-core/build.gradle
@@ -10,8 +10,10 @@ targetCompatibility = 1.8
 
 dependencies {
     api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+    api 'com.google.code.findbugs:jsr305:3.0.2'
+    api 'javax.inject:javax.inject:1' // required for @Qualifier in @Wakeful
 
-    implementation "com.google.dagger:dagger:$hilt_version"
+    implementation "com.google.dagger:hilt-core:$hilt_version"
     kapt "com.google.dagger:dagger-compiler:$hilt_version"
 
     def ktorVersion = '1.6.2'
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletonsModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletonsModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a1e7c297842170d682d509bd1d5b764f5bca4669
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreEagerSingletonsModule.kt
@@ -0,0 +1,28 @@
+package org.briarproject.mailbox.core
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.server.WebServerManager
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class CoreEagerSingletonsModule {
+
+    @Provides
+    @Singleton
+    fun provideEagerSingletons(webServerManager: WebServerManager): CoreEagerSingletons {
+        return CoreEagerSingletons()
+    }
+
+}
+
+class CoreEagerSingletons {
+
+    @Inject
+    internal lateinit var webServerManager: WebServerManager
+
+}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b14ecb17627129c73639980eb90ffce490c6d0c6
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
@@ -0,0 +1,19 @@
+package org.briarproject.mailbox.core
+
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.db.DatabaseModule
+import org.briarproject.mailbox.core.lifecycle.LifecycleModule
+import org.briarproject.mailbox.core.server.WebServerModule
+
+@Module(
+    includes = [
+        CoreEagerSingletonsModule::class,
+        LifecycleModule::class,
+        DatabaseModule::class,
+        WebServerModule::class,
+    ]
+)
+@InstallIn(SingletonComponent::class)
+class CoreModule
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/Service.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/Service.kt
deleted file mode 100644
index 7edf91883510e5ee7f410e877a7fa658fcd0f9df..0000000000000000000000000000000000000000
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/Service.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package org.briarproject.mailbox.core
-
-interface Service {
-}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponent.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponent.java
new file mode 100644
index 0000000000000000000000000000000000000000..88e0e248e8413ebe18e2e1728d623f167e35ce26
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponent.java
@@ -0,0 +1,21 @@
+package org.briarproject.mailbox.core.db;
+
+import javax.annotation.Nullable;
+
+/**
+ * Encapsulates the database implementation and exposes high-level operations
+ * to other components.
+ */
+public interface DatabaseComponent {
+
+    /**
+     * Opens the database and returns true if the database already existed.
+     */
+    boolean open(@Nullable MigrationListener listener);
+
+    /**
+     * Waits for any open transactions to finish and closes the database.
+     */
+    void close();
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponentImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponentImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c82dd66dc8bf6be7c3514c3bea98dbf56189c0fe
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponentImpl.kt
@@ -0,0 +1,14 @@
+package org.briarproject.mailbox.core.db
+
+class DatabaseComponentImpl : DatabaseComponent {
+
+    override fun open(listener: MigrationListener?): Boolean {
+        // TODO: implement this
+        return true;
+    }
+
+    override fun close() {
+        // TODO: implement this
+    }
+
+}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..851fcbe413325f6beda34db552e1f183ae9c830a
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt
@@ -0,0 +1,19 @@
+package org.briarproject.mailbox.core.db
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class DatabaseModule {
+
+    @Provides
+    @Singleton
+    fun provideDatabaseComponent(): DatabaseComponent {
+        return DatabaseComponentImpl()
+    }
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..e12dece37e94b43206a469b8a5521fd73e475d9b
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/MigrationListener.java
@@ -0,0 +1,15 @@
+package org.briarproject.mailbox.core.db;
+
+public interface MigrationListener {
+
+    /**
+     * This is called when a migration is started while opening the database.
+     * It will be called once for each migration being applied.
+     */
+    void onDatabaseMigration();
+
+    /**
+     * This is called when compaction is started while opening the database.
+     */
+    void onDatabaseCompaction();
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..8bf4e72483e842b7b12b10cf7f433ae6c07bd9f4
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
@@ -0,0 +1,103 @@
+package org.briarproject.mailbox.core.lifecycle;
+
+import org.briarproject.mailbox.core.db.DatabaseComponent;
+import org.briarproject.mailbox.core.system.Wakeful;
+
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Manages the lifecycle of the app: opening and closing the
+ * {@link DatabaseComponent} starting and stopping {@link Service Services},
+ * and shutting down {@link ExecutorService ExecutorServices}.
+ */
+public interface LifecycleManager {
+
+    /**
+     * The result of calling {@link #startServices()}.
+     */
+    enum StartResult {
+        ALREADY_RUNNING,
+        SERVICE_ERROR,
+        SUCCESS
+    }
+
+    /**
+     * The state the lifecycle can be in.
+     * Returned by {@link #getLifecycleState()}
+     */
+    enum LifecycleState {
+
+        STARTING, MIGRATING_DATABASE, COMPACTING_DATABASE, STARTING_SERVICES,
+        RUNNING, STOPPING;
+
+        public boolean isAfter(LifecycleState state) {
+            return ordinal() > state.ordinal();
+        }
+    }
+
+    /**
+     * Registers a hook to be called after the database is opened and before
+     * {@link Service services} are started. This method should be called
+     * before {@link #startServices()}.
+     */
+    void registerOpenDatabaseHook(OpenDatabaseHook hook);
+
+    /**
+     * Registers a {@link Service} to be started and stopped. This method
+     * should be called before {@link #startServices()}.
+     */
+    void registerService(Service s);
+
+    /**
+     * Registers an {@link ExecutorService} to be shut down. This method
+     * should be called before {@link #startServices()}.
+     */
+    void registerForShutdown(ExecutorService e);
+
+    /**
+     * Opens the {@link DatabaseComponent} using the given key and starts any
+     * registered {@link Service Services}.
+     */
+    @Wakeful
+    StartResult startServices();
+
+    /**
+     * Stops any registered {@link Service Services}, shuts down any
+     * registered {@link ExecutorService ExecutorServices}, and closes the
+     * {@link DatabaseComponent}.
+     */
+    @Wakeful
+    void stopServices();
+
+    /**
+     * Waits for the {@link DatabaseComponent} to be opened before returning.
+     */
+    void waitForDatabase() throws InterruptedException;
+
+    /**
+     * Waits for the {@link DatabaseComponent} to be opened and all registered
+     * {@link Service Services} to start before returning.
+     */
+    void waitForStartup() throws InterruptedException;
+
+    /**
+     * Waits for all registered {@link Service Services} to stop, all
+     * registered {@link ExecutorService ExecutorServices} to shut down, and
+     * the {@link DatabaseComponent} to be closed before returning.
+     */
+    void waitForShutdown() throws InterruptedException;
+
+    /**
+     * Returns the current state of the lifecycle.
+     */
+    LifecycleState getLifecycleState();
+
+    interface OpenDatabaseHook {
+        /**
+         * Called when the database is being opened, before
+         * {@link #waitForDatabase()} returns.
+         */
+        @Wakeful
+        void onDatabaseOpened();
+    }
+}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..982a62ca34ca354a90e52a6ad8bddc6a15e60726
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.java
@@ -0,0 +1,183 @@
+package org.briarproject.mailbox.core.lifecycle;
+
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.COMPACTING_DATABASE;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.MIGRATING_DATABASE;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.RUNNING;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.STARTING;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.STARTING_SERVICES;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.STOPPING;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.StartResult.ALREADY_RUNNING;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.StartResult.SERVICE_ERROR;
+import static org.briarproject.mailbox.core.lifecycle.LifecycleManager.StartResult.SUCCESS;
+import static org.briarproject.mailbox.core.util.LogUtils.logDuration;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.briarproject.mailbox.core.util.LogUtils.now;
+import static java.util.logging.Level.FINE;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
+
+import org.briarproject.mailbox.core.db.DatabaseComponent;
+import org.briarproject.mailbox.core.db.MigrationListener;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Semaphore;
+import java.util.logging.Logger;
+
+import javax.annotation.concurrent.ThreadSafe;
+import javax.inject.Inject;
+
+@ThreadSafe
+class LifecycleManagerImpl implements LifecycleManager, MigrationListener {
+
+    private static final Logger LOG =
+            getLogger(LifecycleManagerImpl.class.getName());
+
+    private final DatabaseComponent db;
+    private final List<Service> services;
+    private final List<OpenDatabaseHook> openDatabaseHooks;
+    private final List<ExecutorService> executors;
+    private final Semaphore startStopSemaphore = new Semaphore(1);
+    private final CountDownLatch dbLatch = new CountDownLatch(1);
+    private final CountDownLatch startupLatch = new CountDownLatch(1);
+    private final CountDownLatch shutdownLatch = new CountDownLatch(1);
+
+    private volatile LifecycleState state = STARTING;
+
+    @Inject
+    LifecycleManagerImpl(DatabaseComponent db) {
+        this.db = db;
+        services = new CopyOnWriteArrayList<>();
+        openDatabaseHooks = new CopyOnWriteArrayList<>();
+        executors = new CopyOnWriteArrayList<>();
+    }
+
+    @Override
+    public void registerService(Service s) {
+        if (LOG.isLoggable(INFO))
+            LOG.info("Registering service " + s.getClass().getSimpleName());
+        services.add(s);
+    }
+
+    @Override
+    public void registerOpenDatabaseHook(OpenDatabaseHook hook) {
+        if (LOG.isLoggable(INFO)) {
+            LOG.info("Registering open database hook "
+                    + hook.getClass().getSimpleName());
+        }
+        openDatabaseHooks.add(hook);
+    }
+
+    @Override
+    public void registerForShutdown(ExecutorService e) {
+        LOG.info("Registering executor " + e.getClass().getSimpleName());
+        executors.add(e);
+    }
+
+    @Override
+    public StartResult startServices() {
+        if (!startStopSemaphore.tryAcquire()) {
+            LOG.info("Already starting or stopping");
+            return ALREADY_RUNNING;
+        }
+        try {
+            LOG.info("Opening database");
+            long start = now();
+            boolean reopened = db.open(this);
+            if (reopened) logDuration(LOG, "Reopening database", start);
+            else logDuration(LOG, "Creating database", start);
+
+            LOG.info("Starting services");
+            state = STARTING_SERVICES;
+            dbLatch.countDown();
+
+            for (Service s : services) {
+                start = now();
+                s.startService();
+                if (LOG.isLoggable(FINE)) {
+                    logDuration(LOG, "Starting service "
+                            + s.getClass().getSimpleName(), start);
+                }
+            }
+
+            state = RUNNING;
+            startupLatch.countDown();
+            return SUCCESS;
+        } catch (ServiceException e) {
+            logException(LOG, WARNING, e);
+            return SERVICE_ERROR;
+        } finally {
+            startStopSemaphore.release();
+        }
+    }
+
+    @Override
+    public void onDatabaseMigration() {
+        state = MIGRATING_DATABASE;
+    }
+
+    @Override
+    public void onDatabaseCompaction() {
+        state = COMPACTING_DATABASE;
+    }
+
+    @Override
+    public void stopServices() {
+        try {
+            startStopSemaphore.acquire();
+        } catch (InterruptedException e) {
+            LOG.warning("Interrupted while waiting to stop services");
+            return;
+        }
+        try {
+            LOG.info("Stopping services");
+            state = STOPPING;
+            for (Service s : services) {
+                long start = now();
+                s.stopService();
+                if (LOG.isLoggable(FINE)) {
+                    logDuration(LOG, "Stopping service "
+                            + s.getClass().getSimpleName(), start);
+                }
+            }
+            for (ExecutorService e : executors) {
+                if (LOG.isLoggable(FINE)) {
+                    LOG.fine("Stopping executor "
+                            + e.getClass().getSimpleName());
+                }
+                e.shutdownNow();
+            }
+            long start = now();
+            db.close();
+            logDuration(LOG, "Closing database", start);
+            shutdownLatch.countDown();
+        } catch (ServiceException e) {
+            logException(LOG, WARNING, e);
+        } finally {
+            startStopSemaphore.release();
+        }
+    }
+
+    @Override
+    public void waitForDatabase() throws InterruptedException {
+        dbLatch.await();
+    }
+
+    @Override
+    public void waitForStartup() throws InterruptedException {
+        startupLatch.await();
+    }
+
+    @Override
+    public void waitForShutdown() throws InterruptedException {
+        shutdownLatch.await();
+    }
+
+    @Override
+    public LifecycleState getLifecycleState() {
+        return state;
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..781ef6a21f0e630c95f935d2cdd18a25f4283b73
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleModule.kt
@@ -0,0 +1,19 @@
+package org.briarproject.mailbox.core.lifecycle
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class LifecycleModule {
+
+    @Provides
+    @Singleton
+    fun provideLifecycleManager(lifecycleManager: LifecycleManagerImpl): LifecycleManager {
+        return lifecycleManager
+    }
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/Service.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/Service.kt
new file mode 100644
index 0000000000000000000000000000000000000000..cd1a9a86e91cc2c14d5a29182ed9445dc7c3be8b
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/Service.kt
@@ -0,0 +1,22 @@
+package org.briarproject.mailbox.core.lifecycle
+
+import org.briarproject.mailbox.core.system.Wakeful
+
+interface Service {
+
+    /**
+     * Starts the service. This method must not be called concurrently with [stopService].
+     */
+    @Wakeful
+    @Throws(ServiceException::class)
+    fun startService()
+
+    /**
+     * Stops the service. This method must not be called concurrently with
+     * [startService].
+     */
+    @Wakeful
+    @Throws(ServiceException::class)
+    fun stopService()
+
+}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java
new file mode 100644
index 0000000000000000000000000000000000000000..adaec28c8a6c88467f98330793ac9e935f6b9b83
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/ServiceException.java
@@ -0,0 +1,15 @@
+package org.briarproject.mailbox.core.lifecycle;
+
+/**
+ * An exception that indicates an error starting or stopping a {@link Service}.
+ */
+public class ServiceException extends Exception {
+
+    public ServiceException() {
+        super();
+    }
+
+    public ServiceException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
index 3466ed1ca2db1dd9e0c0d47a6c13482ea01e1629..8741eb1edb031ffd63cfcc69a9cca35520d8ccb0 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
@@ -7,12 +7,13 @@ import io.ktor.server.netty.Netty
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
+import org.briarproject.mailbox.core.lifecycle.Service
 import java.util.logging.Logger.getLogger
 import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
-class WebServerManager @Inject constructor() {
+class WebServerManager @Inject constructor() : Service {
 
     internal companion object {
         private const val PORT = 8888
@@ -26,7 +27,7 @@ class WebServerManager @Inject constructor() {
         }
     }
 
-    fun start() {
+    override fun startService() {
         // hangs if not starting inside a coroutine
         GlobalScope.launch(Dispatchers.IO) {
             LOG.info("starting")
@@ -35,7 +36,7 @@ class WebServerManager @Inject constructor() {
         }
     }
 
-    fun stop() {
+    override fun stopService() {
         server.stop(1_000, 2_000)
     }
 
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..37550a3823539a0ab420a0b060c9c0f6a9a6b0aa
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt
@@ -0,0 +1,22 @@
+package org.briarproject.mailbox.core.server
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal class WebServerModule {
+
+    @Provides
+    @Singleton
+    fun provideWebServer(lifecycleManager: LifecycleManager): WebServerManager {
+        val webServerManager = WebServerManager()
+        lifecycleManager.registerService(webServerManager)
+        return webServerManager
+    }
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Wakeful.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Wakeful.java
new file mode 100644
index 0000000000000000000000000000000000000000..a34038db1ad182d519a92b2d94ff46d9328512ea
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/Wakeful.java
@@ -0,0 +1,19 @@
+package org.briarproject.mailbox.core.system;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import javax.inject.Qualifier;
+
+/**
+ * Annotation for methods that must be called while holding a wake lock, if
+ * the platform supports wake locks.
+ */
+@Qualifier
+@Target(METHOD)
+@Retention(RUNTIME)
+public @interface Wakeful {
+}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/LogUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/LogUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..3fb3434b8337c028aef87b6b7bc8c69b4ea02e82
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/LogUtils.java
@@ -0,0 +1,60 @@
+package org.briarproject.mailbox.core.util;
+
+import static java.util.logging.Level.FINE;
+
+import java.io.File;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class LogUtils {
+
+    private static final int NANOS_PER_MILLI = 1000 * 1000;
+
+    /**
+     * Returns the elapsed time in milliseconds since some arbitrary
+     * starting time. This is only useful for measuring elapsed time.
+     */
+    public static long now() {
+        return System.nanoTime() / NANOS_PER_MILLI;
+    }
+
+    /**
+     * Logs the duration of a task.
+     *
+     * @param logger the logger to use
+     * @param task   a description of the task
+     * @param start  the start time of the task, as returned by {@link #now()}
+     */
+    public static void logDuration(Logger logger, String task, long start) {
+        if (logger.isLoggable(FINE)) {
+            long duration = now() - start;
+            logger.fine(task + " took " + duration + " ms");
+        }
+    }
+
+    public static void logException(Logger logger, Level level, Throwable t) {
+        if (logger.isLoggable(level)) logger.log(level, t.toString(), t);
+    }
+
+    public static void logFileOrDir(Logger logger, Level level, File f) {
+        if (logger.isLoggable(level)) {
+            if (f.isFile()) {
+                logWithType(logger, level, f, "F");
+            } else if (f.isDirectory()) {
+                logWithType(logger, level, f, "D");
+                File[] children = f.listFiles();
+                if (children != null) {
+                    for (File child : children)
+                        logFileOrDir(logger, level, child);
+                }
+            } else if (f.exists()) {
+                logWithType(logger, level, f, "?");
+            }
+        }
+    }
+
+    private static void logWithType(Logger logger, Level level, File f,
+                                    String type) {
+        logger.log(level, type + " " + f.getAbsolutePath() + " " + f.length());
+    }
+}