diff --git a/mailbox-android/src/main/AndroidManifest.xml b/mailbox-android/src/main/AndroidManifest.xml
index aa9cb5779b2173a559165fbe0a1249064042fdfe..6d9518fb7ca00122be0b766fa72166ee2604b987 100644
--- a/mailbox-android/src/main/AndroidManifest.xml
+++ b/mailbox-android/src/main/AndroidManifest.xml
@@ -26,6 +26,9 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
+
+        <receiver android:name=".core.system.AlarmReceiver" />
+
     </application>
 
 </manifest>
\ No newline at end of file
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
index 68239a115ae4568bbdf781cf29e6be7174051d97..d4e4de7a15408e80a2b98a5a77e116fd4555511f 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/AppModule.kt
@@ -4,12 +4,11 @@ import dagger.Module
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.CoreModule
-import org.briarproject.mailbox.core.tor.AndroidTorModule
 
 @Module(
     includes = [
         CoreModule::class,
-        AndroidTorModule::class,
+        // Hilt modules from this gradle module are included automatically somehow
     ]
 )
 @InstallIn(SingletonComponent::class)
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 8ff1823fb288124e5a52df349298d701c5bcd04b..62fbc8d1a41a409b09321c636cc2279fd175ecc9 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
@@ -9,14 +9,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.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 org.briarproject.mailbox.core.system.AndroidWakeLockManager
 import org.slf4j.LoggerFactory.getLogger
 import java.util.concurrent.atomic.AtomicBoolean
-
 import javax.inject.Inject
 import kotlin.system.exitProcess
 
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
deleted file mode 100644
index 917feb9037102c346ac4dd0df7c4ee1ecd72da7f..0000000000000000000000000000000000000000
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidSystemModule.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-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/core/AndroidEagerSingletons.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
index baae3905f77ffafb0ce3273466c1149b7614b6d1..1269bb461bb447eb2be2d10f035fcd14a55ac9d4 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/AndroidEagerSingletons.kt
@@ -1,9 +1,13 @@
 package org.briarproject.mailbox.core
 
+import org.briarproject.mailbox.core.system.AndroidTaskScheduler
+import org.briarproject.mailbox.core.tor.AndroidNetworkManager
 import org.briarproject.mailbox.core.tor.AndroidTorPlugin
 import javax.inject.Inject
 
 @Suppress("unused")
 internal class AndroidEagerSingletons @Inject constructor(
+    val androidTaskScheduler: AndroidTaskScheduler,
+    val androidNetworkManager: AndroidNetworkManager,
     val androidTorPlugin: AndroidTorPlugin,
 )
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AlarmConstants.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AlarmConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..6338e44f82cc3eb3c47db8701855c6ac7984191a
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AlarmConstants.java
@@ -0,0 +1,15 @@
+package org.briarproject.mailbox.core.system;
+
+interface AlarmConstants {
+
+    /**
+     * Request code for the broadcast intent attached to the periodic alarm.
+     */
+    int REQUEST_ALARM = 1;
+
+    /**
+     * Key for storing the process ID in the extras of the periodic alarm's
+     * intent. This allows us to ignore alarms scheduled by dead processes.
+     */
+    String EXTRA_PID = "org.briarproject.bramble.EXTRA_PID";
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AlarmReceiver.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AlarmReceiver.java
new file mode 100644
index 0000000000000000000000000000000000000000..8a099905582428c82eb3febd1f5ecb048c7a124d
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AlarmReceiver.java
@@ -0,0 +1,17 @@
+package org.briarproject.mailbox.core.system;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.briarproject.mailbox.android.MailboxApplication;
+
+public class AlarmReceiver extends BroadcastReceiver {
+
+	@Override
+	public void onReceive(Context ctx, Intent intent) {
+		MailboxApplication app =
+				(MailboxApplication) ctx.getApplicationContext();
+		app.androidEagerSingletons.getAndroidTaskScheduler().onAlarm(intent);
+	}
+}
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/core/system/AndroidExecutor.java
similarity index 94%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidExecutor.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidExecutor.java
index 8e9ea64c11cd4d392fae23ef3a04faa259938ffb..a97d710516acd20efa7948574f42da058076a6ad 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidExecutor.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidExecutor.java
@@ -1,4 +1,4 @@
-package org.briarproject.mailbox.android.api.system;
+package org.briarproject.mailbox.core.system;
 
 import java.util.concurrent.Callable;
 import java.util.concurrent.Future;
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidExecutorImpl.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidExecutorImpl.java
similarity index 94%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidExecutorImpl.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidExecutorImpl.java
index 14a66f08868d961b21aaea616b66bc1a3a2832ad..3544757c604beb91832a6f6bb3931ba9d80baeab 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidExecutorImpl.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidExecutorImpl.java
@@ -1,11 +1,9 @@
-package org.briarproject.mailbox.android.system;
+package org.briarproject.mailbox.core.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;
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidSystemModule.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidSystemModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..7e348623bbb388690e8b27bb4120329dfd2b5a8f
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidSystemModule.java
@@ -0,0 +1,59 @@
+package org.briarproject.mailbox.core.system;
+
+import org.briarproject.mailbox.core.event.EventExecutor;
+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 javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+import dagger.hilt.InstallIn;
+import dagger.hilt.components.SingletonComponent;
+
+@Module
+@InstallIn(SingletonComponent.class)
+public class AndroidSystemModule {
+
+	private final ScheduledExecutorService scheduledExecutorService;
+
+	public AndroidSystemModule() {
+		// Discard tasks that are submitted during shutdown
+		RejectedExecutionHandler policy =
+				new ScheduledThreadPoolExecutor.DiscardPolicy();
+		scheduledExecutorService = new ScheduledThreadPoolExecutor(1, policy);
+	}
+
+	@Provides
+	@Singleton
+	ScheduledExecutorService provideScheduledExecutorService(
+			LifecycleManager lifecycleManager) {
+		lifecycleManager.registerForShutdown(scheduledExecutorService);
+		return scheduledExecutorService;
+	}
+
+	@Provides
+	@Singleton
+	AndroidExecutor provideAndroidExecutor(
+			AndroidExecutorImpl androidExecutor) {
+		return androidExecutor;
+	}
+
+	@Provides
+	@Singleton
+	@EventExecutor
+	Executor provideEventExecutor(AndroidExecutor androidExecutor) {
+		return androidExecutor::runOnUiThread;
+	}
+
+	@Provides
+	@Singleton
+	AndroidWakeLockManager provideWakeLockManager(
+			AndroidWakeLockManagerImpl wakeLockManager) {
+		return wakeLockManager;
+	}
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidTaskScheduler.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidTaskScheduler.java
new file mode 100644
index 0000000000000000000000000000000000000000..770b9ef7f806547bf61a99fff0080ecc4df6f4a8
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidTaskScheduler.java
@@ -0,0 +1,231 @@
+package org.briarproject.mailbox.core.system;
+
+import android.annotation.TargetApi;
+import android.app.AlarmManager;
+import android.app.Application;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.os.Process;
+import android.os.SystemClock;
+
+import org.briarproject.mailbox.core.lifecycle.Service;
+import org.slf4j.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.PriorityQueue;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+import static android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP;
+import static android.app.AlarmManager.INTERVAL_FIFTEEN_MINUTES;
+import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
+import static android.content.Context.ALARM_SERVICE;
+import static android.os.Build.VERSION.SDK_INT;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.briarproject.mailbox.core.system.AlarmConstants.EXTRA_PID;
+import static org.briarproject.mailbox.core.system.AlarmConstants.REQUEST_ALARM;
+import static org.briarproject.mailbox.core.util.LogUtils.info;
+import static org.slf4j.LoggerFactory.getLogger;
+
+@ThreadSafe
+public class AndroidTaskScheduler implements TaskScheduler, Service {
+
+	private static final Logger LOG = getLogger(AndroidTaskScheduler.class);
+
+	private static final long ALARM_MS = INTERVAL_FIFTEEN_MINUTES;
+
+	private final Application app;
+	private final AndroidWakeLockManager wakeLockManager;
+	private final ScheduledExecutorService scheduledExecutorService;
+	private final AlarmManager alarmManager;
+
+	private final Object lock = new Object();
+	@GuardedBy("lock")
+	private final Queue<ScheduledTask> tasks = new PriorityQueue<>();
+
+	AndroidTaskScheduler(Application app,
+			AndroidWakeLockManager wakeLockManager,
+			ScheduledExecutorService scheduledExecutorService) {
+		this.app = app;
+		this.wakeLockManager = wakeLockManager;
+		this.scheduledExecutorService = scheduledExecutorService;
+		alarmManager = (AlarmManager) requireNonNull(
+				app.getSystemService(ALARM_SERVICE));
+	}
+
+	@Override
+	public void startService() {
+		scheduleAlarm();
+	}
+
+	@Override
+	public void stopService() {
+		cancelAlarm();
+	}
+
+	@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);
+	}
+
+	public void onAlarm(Intent intent) {
+		wakeLockManager.runWakefully(() -> {
+			int extraPid = intent.getIntExtra(EXTRA_PID, -1);
+			int currentPid = Process.myPid();
+			if (extraPid == currentPid) {
+				LOG.info("Alarm");
+				rescheduleAlarm();
+				runDueTasks();
+			} else {
+				info(LOG, () -> "Ignoring alarm with PID " + extraPid +
+						", current PID is " +
+						currentPid);
+			}
+		}, "TaskAlarm");
+	}
+
+	private Cancellable schedule(Runnable task, Executor executor, long delay,
+			TimeUnit unit, AtomicBoolean cancelled) {
+		long now = SystemClock.elapsedRealtime();
+		long dueMillis = now + MILLISECONDS.convert(delay, unit);
+		Runnable wakeful = () ->
+				wakeLockManager.executeWakefully(task, executor, "TaskHandoff");
+		Future<?> check = scheduleCheckForDueTasks(delay, unit);
+		ScheduledTask s = new ScheduledTask(wakeful, dueMillis, check,
+				cancelled);
+		synchronized (lock) {
+			tasks.add(s);
+		}
+		return s;
+	}
+
+	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);
+	}
+
+	private Future<?> scheduleCheckForDueTasks(long delay, TimeUnit unit) {
+		Runnable wakeful = () -> wakeLockManager.runWakefully(
+				this::runDueTasks, "TaskScheduler");
+		return scheduledExecutorService.schedule(wakeful, delay, unit);
+	}
+
+	@Wakeful
+	private void runDueTasks() {
+		long now = SystemClock.elapsedRealtime();
+		List<ScheduledTask> due = new ArrayList<>();
+		synchronized (lock) {
+			while (true) {
+				ScheduledTask s = tasks.peek();
+				if (s == null || s.dueMillis > now) break;
+				due.add(tasks.remove());
+			}
+		}
+		info(LOG, () -> "Running " + due.size() + " due tasks");
+		for (ScheduledTask s : due) {
+			info(LOG, () -> "Task is " + (now - s.dueMillis) + " ms overdue");
+			s.run();
+		}
+	}
+
+	private void scheduleAlarm() {
+		if (SDK_INT >= 23) scheduleIdleAlarm();
+		else scheduleInexactRepeatingAlarm();
+	}
+
+	private void rescheduleAlarm() {
+		// If SDK_INT < 23 the alarm repeats automatically
+		if (SDK_INT >= 23) scheduleIdleAlarm();
+	}
+
+	private void cancelAlarm() {
+		alarmManager.cancel(getAlarmPendingIntent());
+	}
+
+	private void scheduleInexactRepeatingAlarm() {
+		alarmManager.setInexactRepeating(ELAPSED_REALTIME_WAKEUP,
+				SystemClock.elapsedRealtime() + ALARM_MS, ALARM_MS,
+				getAlarmPendingIntent());
+	}
+
+	@TargetApi(23)
+	private void scheduleIdleAlarm() {
+		alarmManager.setAndAllowWhileIdle(ELAPSED_REALTIME_WAKEUP,
+				SystemClock.elapsedRealtime() + ALARM_MS,
+				getAlarmPendingIntent());
+	}
+
+	private PendingIntent getAlarmPendingIntent() {
+		Intent i = new Intent(app, AlarmReceiver.class);
+		i.putExtra(EXTRA_PID, Process.myPid());
+		return PendingIntent
+				.getBroadcast(app, REQUEST_ALARM, i, FLAG_CANCEL_CURRENT);
+	}
+
+	private class ScheduledTask
+			implements Runnable, Cancellable, Comparable<ScheduledTask> {
+
+		private final Runnable task;
+		private final long dueMillis;
+		private final Future<?> check;
+		private final AtomicBoolean cancelled;
+
+		public ScheduledTask(Runnable task, long dueMillis,
+				Future<?> check, AtomicBoolean cancelled) {
+			this.task = task;
+			this.dueMillis = dueMillis;
+			this.check = check;
+			this.cancelled = cancelled;
+		}
+
+		@Override
+		public void run() {
+			if (!cancelled.get()) task.run();
+		}
+
+		@Override
+		public void cancel() {
+			// Cancel any future executions of this task
+			cancelled.set(true);
+			// Cancel the scheduled check for due tasks
+			check.cancel(false);
+			// Remove the task from the queue
+			synchronized (lock) {
+				tasks.remove(this);
+			}
+		}
+
+		@Override
+		public int compareTo(ScheduledTask s) {
+			//noinspection UseCompareMethod
+			if (dueMillis < s.dueMillis) return -1;
+			if (dueMillis > s.dueMillis) return 1;
+			return 0;
+		}
+	}
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidTaskSchedulerModule.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidTaskSchedulerModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..18074c14205d79d87a07c252347c2de356b9e422
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidTaskSchedulerModule.java
@@ -0,0 +1,37 @@
+package org.briarproject.mailbox.core.system;
+
+import android.app.Application;
+
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+import dagger.hilt.InstallIn;
+import dagger.hilt.components.SingletonComponent;
+
+@Module
+@InstallIn(SingletonComponent.class)
+public class AndroidTaskSchedulerModule {
+
+    @Provides
+    @Singleton
+    AndroidTaskScheduler provideAndroidTaskScheduler(
+            LifecycleManager lifecycleManager, Application app,
+            AndroidWakeLockManager wakeLockManager,
+            ScheduledExecutorService scheduledExecutorService) {
+        AndroidTaskScheduler scheduler = new AndroidTaskScheduler(app,
+                wakeLockManager, scheduledExecutorService);
+        lifecycleManager.registerService(scheduler);
+        return scheduler;
+    }
+
+    @Provides
+    @Singleton
+    TaskScheduler provideTaskScheduler(AndroidTaskScheduler scheduler) {
+        return scheduler;
+    }
+}
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/core/system/AndroidWakeLock.java
similarity index 85%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLock.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLock.java
index f1577efd080a858ecadfbe4cd8d8416a9c793e2d..dd0c8f8fffc910279d50e0e7caa0a9dd750e0b30 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLock.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLock.java
@@ -1,4 +1,4 @@
-package org.briarproject.mailbox.android.api.system;
+package org.briarproject.mailbox.core.system;
 
 public interface AndroidWakeLock {
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockImpl.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockImpl.java
similarity index 93%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockImpl.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockImpl.java
index 6eaa65eb325613973ed73079f64338f800836c9c..32b67fc62c955005a172217fac729d6c9d722cc3 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockImpl.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockImpl.java
@@ -1,9 +1,5 @@
-package org.briarproject.mailbox.android.system;
+package org.briarproject.mailbox.core.system;
 
-import static org.briarproject.mailbox.core.util.LogUtils.trace;
-import static org.slf4j.LoggerFactory.getLogger;
-
-import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
 import org.slf4j.Logger;
 
 import java.util.concurrent.atomic.AtomicInteger;
@@ -11,6 +7,9 @@ import java.util.concurrent.atomic.AtomicInteger;
 import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 
+import static org.briarproject.mailbox.core.util.LogUtils.trace;
+import static org.slf4j.LoggerFactory.getLogger;
+
 /**
  * A wrapper around a {@link SharedWakeLock} that provides the more convenient
  * semantics of {@link AndroidWakeLock} (i.e. calls to acquire() and 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/core/system/AndroidWakeLockManager.java
similarity index 95%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLockManager.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockManager.java
index 917d10b191d21421151d2d98b300b7c1408e4bc6..7b2cd233b9d0044b8b93b81184f0321ecd279a03 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/api/system/AndroidWakeLockManager.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockManager.java
@@ -1,4 +1,4 @@
-package org.briarproject.mailbox.android.api.system;
+package org.briarproject.mailbox.core.system;
 
 import java.util.concurrent.Executor;
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockManagerImpl.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockManagerImpl.java
similarity index 95%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockManagerImpl.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockManagerImpl.java
index bb590249d6affd5abebd7404e70a63f600e300c2..0cbd9a087b454789a7727d6c20f945f96d4782a5 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/AndroidWakeLockManagerImpl.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/AndroidWakeLockManagerImpl.java
@@ -1,24 +1,21 @@
-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;
+package org.briarproject.mailbox.core.system;
 
 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;
 
+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;
+
 class AndroidWakeLockManagerImpl implements AndroidWakeLockManager {
 
     /**
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/RenewableWakeLock.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/RenewableWakeLock.java
similarity index 98%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/system/RenewableWakeLock.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/RenewableWakeLock.java
index a39cbf5f53ff7759762707f4ae8a08af2edc74dd..0cd45bc6de686534a1c3b0e3d28d2a419bb35618 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/RenewableWakeLock.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/RenewableWakeLock.java
@@ -1,11 +1,4 @@
-package org.briarproject.mailbox.android.system;
-
-import static org.briarproject.mailbox.core.util.LogUtils.info;
-import static org.briarproject.mailbox.core.util.LogUtils.trace;
-import static org.briarproject.mailbox.core.util.LogUtils.warn;
-import static org.slf4j.LoggerFactory.getLogger;
-import static java.util.Objects.requireNonNull;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
+package org.briarproject.mailbox.core.system;
 
 import android.os.PowerManager;
 import android.os.PowerManager.WakeLock;
@@ -19,6 +12,13 @@ import javax.annotation.Nullable;
 import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.briarproject.mailbox.core.util.LogUtils.info;
+import static org.briarproject.mailbox.core.util.LogUtils.trace;
+import static org.briarproject.mailbox.core.util.LogUtils.warn;
+import static org.slf4j.LoggerFactory.getLogger;
+
 @ThreadSafe
 class RenewableWakeLock implements SharedWakeLock {
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/SharedWakeLock.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/SharedWakeLock.java
similarity index 82%
rename from mailbox-android/src/main/java/org/briarproject/mailbox/android/system/SharedWakeLock.java
rename to mailbox-android/src/main/java/org/briarproject/mailbox/core/system/SharedWakeLock.java
index a4e353de45c2e690a75299f698b3371968ff2f9f..d193a7806cbe6a0974eae6cb44fb848bfeda4031 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/system/SharedWakeLock.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/system/SharedWakeLock.java
@@ -1,6 +1,4 @@
-package org.briarproject.mailbox.android.system;
-
-import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
+package org.briarproject.mailbox.core.system;
 
 interface SharedWakeLock {
 
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidLocationUtils.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidLocationUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..b189c3d43f20b0cafeba86d04c93e096e16d9ad3
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidLocationUtils.java
@@ -0,0 +1,70 @@
+package org.briarproject.mailbox.core.tor;
+
+import static android.content.Context.TELEPHONY_SERVICE;
+
+import android.annotation.SuppressLint;
+import android.app.Application;
+import android.content.Context;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+
+import org.briarproject.mailbox.core.system.LocationUtils;
+
+import java.util.Locale;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+class AndroidLocationUtils implements LocationUtils {
+
+    private static final Logger LOG =
+            Logger.getLogger(AndroidLocationUtils.class.getName());
+
+    private final Context appContext;
+
+    @Inject
+    AndroidLocationUtils(Application app) {
+        appContext = app.getApplicationContext();
+    }
+
+    /**
+     * This guesses the current country from the first of these sources that
+     * succeeds (also in order of likelihood of being correct):
+     *
+     * <ul>
+     * <li>Phone network. This works even when no SIM card is inserted, or a
+     *     foreign SIM card is inserted.</li>
+     * <li>SIM card. This is only an heuristic and assumes the user is not
+     *     roaming.</li>
+     * <li>User locale. This is an even worse heuristic.</li>
+     * </ul>
+     * <p>
+     * Note: this is very similar to <a href="https://android.googlesource.com/platform/frameworks/base/+/cd92588%5E/location/java/android/location/CountryDetector.java">
+     * this API</a> except it seems that Google doesn't want us to use it for
+     * some reason - both that class and {@code Context.COUNTRY_CODE} are
+     * annotated {@code @hide}.
+     */
+    @Override
+    @SuppressLint("DefaultLocale")
+    public String getCurrentCountry() {
+        String countryCode = getCountryFromPhoneNetwork();
+        if (!TextUtils.isEmpty(countryCode)) return countryCode.toUpperCase();
+        LOG.info("Falling back to SIM card country");
+        countryCode = getCountryFromSimCard();
+        if (!TextUtils.isEmpty(countryCode)) return countryCode.toUpperCase();
+        LOG.info("Falling back to user-defined locale");
+        return Locale.getDefault().getCountry();
+    }
+
+    private String getCountryFromPhoneNetwork() {
+        Object o = appContext.getSystemService(TELEPHONY_SERVICE);
+        TelephonyManager tm = (TelephonyManager) o;
+        return tm == null ? "" : tm.getNetworkCountryIso();
+    }
+
+    private String getCountryFromSimCard() {
+        Object o = appContext.getSystemService(TELEPHONY_SERVICE);
+        TelephonyManager tm = (TelephonyManager) o;
+        return tm == null ? "" : tm.getSimCountryIso();
+    }
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidNetworkManager.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidNetworkManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..1743c3c477f40bb4dd90bf6003fc800fe9cd1a74
--- /dev/null
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidNetworkManager.java
@@ -0,0 +1,215 @@
+package org.briarproject.mailbox.core.tor;
+
+import static android.content.Context.CONNECTIVITY_SERVICE;
+import static android.content.Intent.ACTION_SCREEN_OFF;
+import static android.content.Intent.ACTION_SCREEN_ON;
+import static android.net.ConnectivityManager.CONNECTIVITY_ACTION;
+import static android.net.ConnectivityManager.TYPE_WIFI;
+import static android.net.wifi.p2p.WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION;
+import static android.os.Build.VERSION.SDK_INT;
+import static android.os.PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED;
+import static org.briarproject.mailbox.core.util.LogUtils.info;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.slf4j.LoggerFactory.getLogger;
+import static java.net.NetworkInterface.getNetworkInterfaces;
+import static java.util.Collections.list;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.annotation.TargetApi;
+import android.app.Application;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkInfo;
+
+import org.briarproject.mailbox.core.event.EventBus;
+import org.briarproject.mailbox.core.event.EventExecutor;
+import org.briarproject.mailbox.core.lifecycle.Service;
+import org.briarproject.mailbox.core.system.TaskScheduler;
+import org.briarproject.mailbox.core.system.TaskScheduler.Cancellable;
+import org.slf4j.Logger;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.util.Enumeration;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+public class AndroidNetworkManager implements NetworkManager, Service {
+
+    private static final Logger LOG = getLogger(AndroidNetworkManager.class);
+
+    // See android.net.wifi.WifiManager
+    private static final String WIFI_AP_STATE_CHANGED_ACTION =
+            "android.net.wifi.WIFI_AP_STATE_CHANGED";
+
+    private final TaskScheduler scheduler;
+    private final EventBus eventBus;
+    private final Executor eventExecutor;
+    private final Application app;
+    private final ConnectivityManager connectivityManager;
+    private final AtomicReference<Cancellable> connectivityCheck =
+            new AtomicReference<>();
+    private final AtomicBoolean used = new AtomicBoolean(false);
+
+    private volatile BroadcastReceiver networkStateReceiver = null;
+
+    @Inject
+    AndroidNetworkManager(TaskScheduler scheduler, EventBus eventBus,
+                          @EventExecutor Executor eventExecutor, Application app) {
+        this.scheduler = scheduler;
+        this.eventBus = eventBus;
+        this.eventExecutor = eventExecutor;
+        this.app = app;
+        connectivityManager = (ConnectivityManager)
+                requireNonNull(app.getSystemService(CONNECTIVITY_SERVICE));
+    }
+
+    @Override
+    public void startService() {
+        if (used.getAndSet(true)) throw new IllegalStateException();
+        // Register to receive network status events
+        networkStateReceiver = new NetworkStateReceiver();
+        IntentFilter filter = new IntentFilter();
+        filter.addAction(CONNECTIVITY_ACTION);
+        filter.addAction(ACTION_SCREEN_ON);
+        filter.addAction(ACTION_SCREEN_OFF);
+        filter.addAction(WIFI_AP_STATE_CHANGED_ACTION);
+        filter.addAction(WIFI_P2P_THIS_DEVICE_CHANGED_ACTION);
+        if (SDK_INT >= 23) filter.addAction(ACTION_DEVICE_IDLE_MODE_CHANGED);
+        app.registerReceiver(networkStateReceiver, filter);
+    }
+
+    @Override
+    public void stopService() {
+        if (networkStateReceiver != null)
+            app.unregisterReceiver(networkStateReceiver);
+    }
+
+    @Override
+    public NetworkStatus getNetworkStatus() {
+        NetworkInfo net = connectivityManager.getActiveNetworkInfo();
+        boolean connected = net != null && net.isConnected();
+        boolean wifi = false, ipv6Only = false;
+        if (connected) {
+            wifi = net.getType() == TYPE_WIFI;
+            if (SDK_INT >= 23) ipv6Only = isActiveNetworkIpv6Only();
+            else ipv6Only = areAllAvailableNetworksIpv6Only();
+        }
+        return new NetworkStatus(connected, wifi, ipv6Only);
+    }
+
+    /**
+     * Returns true if the
+     * {@link ConnectivityManager#getActiveNetwork() active network} has an
+     * IPv6 unicast address and no IPv4 addresses. The active network is
+     * assumed not to be a loopback interface.
+     */
+    @TargetApi(23)
+    private boolean isActiveNetworkIpv6Only() {
+        Network net = connectivityManager.getActiveNetwork();
+        if (net == null) {
+            LOG.info("No active network");
+            return false;
+        }
+        LinkProperties props = connectivityManager.getLinkProperties(net);
+        if (props == null) {
+            LOG.info("No link properties for active network");
+            return false;
+        }
+        boolean hasIpv6Unicast = false;
+        for (LinkAddress linkAddress : props.getLinkAddresses()) {
+            InetAddress addr = linkAddress.getAddress();
+            if (addr instanceof Inet4Address) return false;
+            if (!addr.isMulticastAddress()) hasIpv6Unicast = true;
+        }
+        return hasIpv6Unicast;
+    }
+
+    /**
+     * Returns true if the device has at least one network interface with an
+     * IPv6 unicast address and no interfaces with IPv4 addresses, excluding
+     * loopback interfaces and interfaces that are
+     * {@link NetworkInterface#isUp() down}. If this method returns true and
+     * the device has internet access then it's via IPv6 only.
+     */
+    private boolean areAllAvailableNetworksIpv6Only() {
+        try {
+            Enumeration<NetworkInterface> interfaces = getNetworkInterfaces();
+            if (interfaces == null) {
+                LOG.info("No network interfaces");
+                return false;
+            }
+            boolean hasIpv6Unicast = false;
+            for (NetworkInterface i : list(interfaces)) {
+                if (i.isLoopback() || !i.isUp()) continue;
+                for (InetAddress addr : list(i.getInetAddresses())) {
+                    if (addr instanceof Inet4Address) return false;
+                    if (!addr.isMulticastAddress()) hasIpv6Unicast = true;
+                }
+            }
+            return hasIpv6Unicast;
+        } catch (SocketException e) {
+            logException(LOG, e);
+            return false;
+        }
+    }
+
+    private void updateConnectionStatus() {
+        eventBus.broadcast(new NetworkStatusEvent(getNetworkStatus()));
+    }
+
+    private void scheduleConnectionStatusUpdate(int delay, TimeUnit unit) {
+        Cancellable newConnectivityCheck =
+                scheduler.schedule(this::updateConnectionStatus, eventExecutor,
+                        delay, unit);
+        Cancellable oldConnectivityCheck =
+                connectivityCheck.getAndSet(newConnectivityCheck);
+        if (oldConnectivityCheck != null) oldConnectivityCheck.cancel();
+    }
+
+    private class NetworkStateReceiver extends BroadcastReceiver {
+
+        @Override
+        public void onReceive(Context ctx, Intent i) {
+            String action = i.getAction();
+            info(LOG, () -> "Received broadcast " + action);
+            updateConnectionStatus();
+            if (isSleepOrDozeEvent(action)) {
+                // Allow time for the network to be enabled or disabled
+                scheduleConnectionStatusUpdate(1, MINUTES);
+            } else if (isApEvent(action)) {
+                // The state change may be broadcast before the AP address is
+                // visible, so delay handling the event
+                scheduleConnectionStatusUpdate(5, SECONDS);
+            }
+        }
+
+        private boolean isSleepOrDozeEvent(@Nullable String action) {
+            boolean isSleep = ACTION_SCREEN_ON.equals(action) ||
+                    ACTION_SCREEN_OFF.equals(action);
+            boolean isDoze = SDK_INT >= 23 &&
+                    ACTION_DEVICE_IDLE_MODE_CHANGED.equals(action);
+            return isSleep || isDoze;
+        }
+
+        private boolean isApEvent(@Nullable String action) {
+            return WIFI_AP_STATE_CHANGED_ACTION.equals(action) ||
+                    WIFI_P2P_THIS_DEVICE_CHANGED_ACTION.equals(action);
+        }
+    }
+}
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt
index d6072abadb9a6247f81e62a96070a0d54d89753e..383759435419f726ff5778d6c9d7f30abebb3cf8 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorModule.kt
@@ -7,9 +7,10 @@ import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.android.qualifiers.ApplicationContext
 import dagger.hilt.components.SingletonComponent
-import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager
+import org.briarproject.mailbox.core.event.EventBus
 import org.briarproject.mailbox.core.lifecycle.IoExecutor
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.core.system.AndroidWakeLockManager
 import org.briarproject.mailbox.core.system.Clock
 import org.briarproject.mailbox.core.system.LocationUtils
 import org.briarproject.mailbox.core.system.ResourceProvider
@@ -50,6 +51,7 @@ internal class AndroidTorModule {
         androidWakeLockManager: AndroidWakeLockManager,
         backoff: Backoff,
         lifecycleManager: LifecycleManager,
+        eventBus: EventBus,
     ) = AndroidTorPlugin(
         ioExecutor,
         app,
@@ -62,7 +64,10 @@ internal class AndroidTorModule {
         backoff,
         architecture,
         app.getDir("tor", Context.MODE_PRIVATE),
-    ).also { lifecycleManager.registerService(it) }
+    ).also {
+        lifecycleManager.registerService(it)
+        eventBus.addListener(it)
+    }
 
     private val architecture: String?
         get() {
@@ -79,4 +84,19 @@ internal class AndroidTorModule {
             return null
         }
 
+    @Provides
+    fun provideLocationUtils(locationUtils: AndroidLocationUtils): LocationUtils {
+        return locationUtils
+    }
+
+    @Provides
+    @Singleton
+    fun provideNetworkManager(
+        lifecycleManager: LifecycleManager,
+        networkManager: AndroidNetworkManager,
+    ): NetworkManager {
+        lifecycleManager.registerService(networkManager)
+        return networkManager
+    }
+
 }
diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
index 4c46231e0fd2c43aca82f842b1bc75ecd0aee70e..0992a4674e118abd9157f8abc4b1b52e4631a76b 100644
--- a/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
+++ b/mailbox-android/src/main/java/org/briarproject/mailbox/core/tor/AndroidTorPlugin.java
@@ -11,8 +11,8 @@ import android.content.pm.PackageManager;
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.os.Build;
 
-import org.briarproject.mailbox.android.api.system.AndroidWakeLock;
-import org.briarproject.mailbox.android.api.system.AndroidWakeLockManager;
+import org.briarproject.mailbox.core.system.AndroidWakeLock;
+import org.briarproject.mailbox.core.system.AndroidWakeLockManager;
 import org.briarproject.mailbox.core.system.Clock;
 import org.briarproject.mailbox.core.system.LocationUtils;
 import org.briarproject.mailbox.core.system.ResourceProvider;
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
index ee6c354a3c2cf83efe9b2bfed34d1beef61d547c..3638ea3a6125418665f874ae26bc1d29e49c7049 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/cli/JavaCliModule.kt
@@ -4,11 +4,15 @@ import dagger.Module
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.CoreModule
+import org.briarproject.mailbox.core.event.DefaultEventExecutorModule
+import org.briarproject.mailbox.core.system.DefaultTaskSchedulerModule
 import org.briarproject.mailbox.core.tor.JavaTorModule
 
 @Module(
     includes = [
         CoreModule::class,
+        DefaultEventExecutorModule::class,
+        DefaultTaskSchedulerModule::class,
         JavaTorModule::class,
     ]
 )
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
index ff5328b0bfca3e9b1fef791e12212a8f704ec039..446a1cc524401b85bd76e56e518c5c7f84d652bf 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/JavaCliEagerSingletons.kt
@@ -1,9 +1,11 @@
 package org.briarproject.mailbox.core
 
+import org.briarproject.mailbox.core.system.TaskScheduler
 import org.briarproject.mailbox.core.tor.JavaTorPlugin
 import javax.inject.Inject
 
 @Suppress("unused")
 internal class JavaCliEagerSingletons @Inject constructor(
+    val taskScheduler: TaskScheduler,
     val javaTorPlugin: JavaTorPlugin,
 )
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..133055c3ec926b5941ca639d7214e3d4687c1c25
--- /dev/null
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaCliNetworkManager.java
@@ -0,0 +1,46 @@
+package org.briarproject.mailbox.core.tor;
+
+import org.slf4j.Logger;
+
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+
+import javax.inject.Inject;
+
+import static java.util.Collections.list;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.briarproject.mailbox.core.util.NetworkUtils.getNetworkInterfaces;
+import static org.slf4j.LoggerFactory.getLogger;
+
+class JavaCliNetworkManager implements NetworkManager {
+
+    private static final Logger LOG = getLogger(JavaCliNetworkManager.class);
+
+    @Inject
+    JavaCliNetworkManager() {
+    }
+
+    @Override
+    public NetworkStatus getNetworkStatus() {
+        boolean connected = false, hasIpv4 = false, hasIpv6Unicast = false;
+        try {
+            for (NetworkInterface i : getNetworkInterfaces()) {
+                if (i.isLoopback() || !i.isUp()) continue;
+                for (InetAddress addr : list(i.getInetAddresses())) {
+                    connected = true;
+                    if (addr instanceof Inet4Address) {
+                        hasIpv4 = true;
+                    } else if (!addr.isMulticastAddress()) {
+                        hasIpv6Unicast = true;
+                    }
+                }
+            }
+        } catch (SocketException e) {
+            logException(LOG, e);
+        }
+        return new NetworkStatus(connected, false, !hasIpv4 && hasIpv6Unicast);
+    }
+
+}
diff --git a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt
index c70b23b72d271b2bc59c610fb455dbd359d6a4b8..4454de7df29d6b2a23ddd196d631faeb490bb982 100644
--- a/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt
+++ b/mailbox-cli/src/main/java/org/briarproject/mailbox/core/tor/JavaTorModule.kt
@@ -4,6 +4,7 @@ import dagger.Module
 import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
+import org.briarproject.mailbox.core.event.EventBus
 import org.briarproject.mailbox.core.lifecycle.IoExecutor
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.system.Clock
@@ -11,8 +12,9 @@ import org.briarproject.mailbox.core.system.LocationUtils
 import org.briarproject.mailbox.core.system.ResourceProvider
 import org.briarproject.mailbox.core.util.OsUtils.isLinux
 import org.slf4j.Logger
-import org.slf4j.LoggerFactory
+import org.slf4j.LoggerFactory.getLogger
 import java.io.File
+import java.util.Locale
 import java.util.concurrent.Executor
 import javax.inject.Singleton
 
@@ -21,7 +23,7 @@ import javax.inject.Singleton
 internal class JavaTorModule {
 
     companion object {
-        private val LOG: Logger = LoggerFactory.getLogger(JavaTorModule::class.java)
+        private val LOG: Logger = getLogger(JavaTorModule::class.java)
     }
 
     @Provides
@@ -42,6 +44,7 @@ internal class JavaTorModule {
         circumventionProvider: CircumventionProvider,
         backoff: Backoff,
         lifecycleManager: LifecycleManager,
+        eventBus: EventBus,
     ): JavaTorPlugin {
         val configDir = File(System.getProperty("user.home") + File.separator + ".config")
         val mailboxDir = File(configDir, ".briar-mailbox")
@@ -56,7 +59,10 @@ internal class JavaTorModule {
             backoff,
             architecture,
             torDir,
-        ).also { lifecycleManager.registerService(it) }
+        ).also {
+            lifecycleManager.registerService(it)
+            eventBus.addListener(it)
+        }
     }
 
     private val architecture: String?
@@ -81,4 +87,14 @@ internal class JavaTorModule {
             return null
         }
 
+    @Provides
+    @Singleton
+    fun provideNetworkManager(networkManager: JavaCliNetworkManager): NetworkManager {
+        return networkManager
+    }
+
+    @Provides
+    @Singleton
+    fun provideLocationUtils() = LocationUtils { Locale.getDefault().country }
+
 }
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
index b0c37df01a5b6bfe334310af7385ac377b8b8d17..229da7f21a7e0cf4d8916406a3e7556b2faf13e8 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/CoreModule.kt
@@ -5,6 +5,7 @@ import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.db.DatabaseModule
+import org.briarproject.mailbox.core.event.EventModule
 import org.briarproject.mailbox.core.lifecycle.LifecycleModule
 import org.briarproject.mailbox.core.server.WebServerModule
 import org.briarproject.mailbox.core.system.Clock
@@ -13,6 +14,7 @@ import javax.inject.Singleton
 
 @Module(
     includes = [
+        EventModule::class,
         LifecycleModule::class,
         DatabaseModule::class,
         WebServerModule::class,
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
index 671842ce53737f946533ce9d418d28c37260efe4..4149077b7c878a6c494b8e561db5f0421909b2c6 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/PoliteExecutor.java
@@ -1,8 +1,5 @@
 package org.briarproject.mailbox.core;
 
-import static org.briarproject.mailbox.core.util.LogUtils.now;
-import static java.util.logging.Level.FINE;
-
 import java.util.LinkedList;
 import java.util.Queue;
 import java.util.concurrent.Executor;
@@ -10,6 +7,9 @@ import java.util.logging.Logger;
 
 import javax.annotation.concurrent.GuardedBy;
 
+import static java.util.logging.Level.FINE;
+import static org.briarproject.mailbox.core.util.LogUtils.now;
+
 /**
  * An {@link Executor} that delegates its tasks to another {@link Executor}
  * while limiting the number of tasks that are delegated concurrently. Tasks
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/DefaultEventExecutorModule.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/DefaultEventExecutorModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..675d81de60b0ed93ef4d2d75bb18324f669e7060
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/DefaultEventExecutorModule.java
@@ -0,0 +1,33 @@
+package org.briarproject.mailbox.core.event;
+
+import java.util.concurrent.Executor;
+
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+import dagger.hilt.InstallIn;
+import dagger.hilt.components.SingletonComponent;
+
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+
+/**
+ * Default implementation of {@link EventExecutor} that uses a dedicated thread
+ * to notify listeners of events. Applications may prefer to supply an
+ * implementation that uses an existing thread, such as the UI thread.
+ */
+@Module
+@InstallIn(SingletonComponent.class)
+public class DefaultEventExecutorModule {
+
+	@Provides
+	@Singleton
+	@EventExecutor
+	Executor provideEventExecutor() {
+		return newSingleThreadExecutor(r -> {
+			Thread t = new Thread(r);
+			t.setDaemon(true);
+			return t;
+		});
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/Event.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/Event.java
new file mode 100644
index 0000000000000000000000000000000000000000..8faa67ecb985885402ab5db6deadede77323d915
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/Event.java
@@ -0,0 +1,7 @@
+package org.briarproject.mailbox.core.event;
+
+/**
+ * An abstract superclass for events.
+ */
+public abstract class Event {
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventBus.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventBus.java
new file mode 100644
index 0000000000000000000000000000000000000000..b5fe6a10b12309a4440105236bdbe83fe0b84177
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventBus.java
@@ -0,0 +1,22 @@
+package org.briarproject.mailbox.core.event;
+
+public interface EventBus {
+
+	/**
+	 * Adds a listener to be notified when events occur.
+	 */
+	void addListener(EventListener l);
+
+	/**
+	 * Removes a listener.
+	 */
+	void removeListener(EventListener l);
+
+	/**
+	 * Asynchronously notifies all listeners of an event. Listeners are
+	 * notified on the {@link EventExecutor}.
+	 * <p>
+	 * This method can safely be called while holding a lock.
+	 */
+	void broadcast(Event e);
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventBusImpl.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventBusImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..ae357248b7584837ebbfb510904eff82855ef20d
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventBusImpl.java
@@ -0,0 +1,38 @@
+package org.briarproject.mailbox.core.event;
+
+import java.util.Collection;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executor;
+
+import javax.annotation.concurrent.ThreadSafe;
+import javax.inject.Inject;
+
+@ThreadSafe
+class EventBusImpl implements EventBus {
+
+	private final Collection<EventListener> listeners =
+			new CopyOnWriteArrayList<>();
+	private final Executor eventExecutor;
+
+	@Inject
+	EventBusImpl(@EventExecutor Executor eventExecutor) {
+		this.eventExecutor = eventExecutor;
+	}
+
+	@Override
+	public void addListener(EventListener l) {
+		listeners.add(l);
+	}
+
+	@Override
+	public void removeListener(EventListener l) {
+		listeners.remove(l);
+	}
+
+	@Override
+	public void broadcast(Event e) {
+		eventExecutor.execute(() -> {
+			for (EventListener l : listeners) l.eventOccurred(e);
+		});
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventExecutor.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventExecutor.java
new file mode 100644
index 0000000000000000000000000000000000000000..b8c03f18042207133dff78c52c0468cb4ba3d000
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventExecutor.java
@@ -0,0 +1,26 @@
+package org.briarproject.mailbox.core.event;
+
+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 executor for broadcasting events and running
+ * tasks that need to run in a defined order with respect to events. Also used
+ * for annotating methods that should run on the event executor.
+ * <p>
+ * The contract of this executor is that tasks are run in the order they're
+ * submitted, tasks are not run concurrently, and submitting a task will never
+ * block. Tasks must not block. Tasks submitted during shutdown are discarded.
+ */
+@Qualifier
+@Target({FIELD, METHOD, PARAMETER})
+@Retention(RUNTIME)
+public @interface EventExecutor {
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventListener.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..97b423852d54752cccc5115beaa2020870427385
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventListener.java
@@ -0,0 +1,14 @@
+package org.briarproject.mailbox.core.event;
+
+/**
+ * An interface for receiving notifications when events occur.
+ */
+public interface EventListener {
+
+	/**
+	 * Called when an event is broadcast. Implementations of this method must
+	 * not block.
+	 */
+	@EventExecutor
+	void eventOccurred(Event e);
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventModule.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..8319a16cea839335a6af057cda49490bc50809b8
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/event/EventModule.java
@@ -0,0 +1,19 @@
+package org.briarproject.mailbox.core.event;
+
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+import dagger.hilt.InstallIn;
+import dagger.hilt.components.SingletonComponent;
+
+@Module
+@InstallIn(SingletonComponent.class)
+public class EventModule {
+
+	@Provides
+	@Singleton
+	EventBus provideEventBus(EventBusImpl eventBus) {
+		return eventBus;
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java
index 45e8c51f15bc79ca99d84cd20c1e10ef055718d7..6008a696a0998255dede80e994415ce93a380bf5 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/IoExecutor.java
@@ -1,15 +1,15 @@
 package org.briarproject.mailbox.core.lifecycle;
 
-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;
-
 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 executor for long-running IO tasks. Also used
  * for annotating methods that should run on the IO executor.
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/DefaultTaskSchedulerModule.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/DefaultTaskSchedulerModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..1bbb56cf51559bd09ec9a40a2bfa5ac720e06dda
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/DefaultTaskSchedulerModule.java
@@ -0,0 +1,32 @@
+package org.briarproject.mailbox.core.system;
+
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager;
+
+import java.util.concurrent.RejectedExecutionHandler;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+import javax.inject.Singleton;
+
+import dagger.Module;
+import dagger.Provides;
+
+@Module
+public class DefaultTaskSchedulerModule {
+
+	private final ScheduledExecutorService scheduledExecutorService;
+
+	public DefaultTaskSchedulerModule() {
+		// Discard tasks that are submitted during shutdown
+		RejectedExecutionHandler policy =
+				new ScheduledThreadPoolExecutor.DiscardPolicy();
+		scheduledExecutorService = new ScheduledThreadPoolExecutor(1, policy);
+	}
+
+	@Provides
+	@Singleton
+	TaskScheduler provideTaskScheduler(LifecycleManager lifecycleManager) {
+		lifecycleManager.registerForShutdown(scheduledExecutorService);
+		return new TaskSchedulerImpl(scheduledExecutorService);
+	}
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/TaskScheduler.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/TaskScheduler.java
new file mode 100644
index 0000000000000000000000000000000000000000..83972016ece1d036c57b713917762ea04a2af60d
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/TaskScheduler.java
@@ -0,0 +1,40 @@
+package org.briarproject.mailbox.core.system;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A service that can be used to schedule the execution of tasks.
+ */
+public interface TaskScheduler {
+
+    /**
+     * Submits the given task to the given executor after the given delay.
+     * <p>
+     * If the platform supports wake locks, a wake lock will be held while
+     * submitting and running the task.
+     */
+    Cancellable schedule(Runnable task, Executor executor, long delay,
+                         TimeUnit unit);
+
+    /**
+     * Submits the given task to the given executor after the given delay,
+     * and then repeatedly with the given interval between executions
+     * (measured from the end of one execution to the beginning of the next).
+     * <p>
+     * If the platform supports wake locks, a wake lock will be held while
+     * submitting and running the task.
+     */
+    Cancellable scheduleWithFixedDelay(Runnable task, Executor executor,
+                                       long delay, long interval, TimeUnit unit);
+
+    interface Cancellable {
+
+        /**
+         * Cancels the task if it has not already started running. If the task
+         * is {@link #scheduleWithFixedDelay(Runnable, Executor, long, long, TimeUnit) periodic},
+         * all future executions of the task are cancelled.
+         */
+        void cancel();
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/TaskSchedulerImpl.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/TaskSchedulerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..80117a82d265d396f76b300ec8ff40a6c99c52df
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/system/TaskSchedulerImpl.java
@@ -0,0 +1,39 @@
+package org.briarproject.mailbox.core.system;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A {@link TaskScheduler} that uses a {@link ScheduledExecutorService}.
+ */
+@ThreadSafe
+class TaskSchedulerImpl implements TaskScheduler {
+
+    private final ScheduledExecutorService scheduledExecutorService;
+
+    TaskSchedulerImpl(ScheduledExecutorService scheduledExecutorService) {
+        this.scheduledExecutorService = scheduledExecutorService;
+    }
+
+    @Override
+    public Cancellable schedule(Runnable task, Executor executor, long delay,
+                                TimeUnit unit) {
+        Runnable execute = () -> executor.execute(task);
+        ScheduledFuture<?> future =
+                scheduledExecutorService.schedule(execute, delay, unit);
+        return () -> future.cancel(false);
+    }
+
+    @Override
+    public Cancellable scheduleWithFixedDelay(Runnable task, Executor executor,
+                                              long delay, long interval, TimeUnit unit) {
+        Runnable execute = () -> executor.execute(task);
+        ScheduledFuture<?> future = scheduledExecutorService.
+                scheduleWithFixedDelay(execute, delay, interval, unit);
+        return () -> future.cancel(false);
+    }
+}
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
index a34038db1ad182d519a92b2d94ff46d9328512ea..02a0218b984089454fb8e7a8a6a7211c02873684 100644
--- 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
@@ -1,13 +1,13 @@
 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;
 
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
 /**
  * Annotation for methods that must be called while holding a wake lock, if
  * the platform supports wake locks.
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkStatusEvent.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkStatusEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..db9366a553263a5551ebc56bab4dc4eaa20ea3b6
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/NetworkStatusEvent.java
@@ -0,0 +1,19 @@
+package org.briarproject.mailbox.core.tor;
+
+import org.briarproject.mailbox.core.event.Event;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+public class NetworkStatusEvent extends Event {
+
+    private final NetworkStatus status;
+
+    public NetworkStatusEvent(NetworkStatus status) {
+        this.status = status;
+    }
+
+    public NetworkStatus getStatus() {
+        return status;
+    }
+}
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt
index 833f19012bbff51d4f09fcaab2ea1cb7d204dfcb..c1afee5d4b9cadbeabb5f68f61360e5fffdac7ca 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorModule.kt
@@ -5,7 +5,6 @@ import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.lifecycle.IoExecutor
-import org.briarproject.mailbox.core.system.LocationUtils
 import java.util.concurrent.Executor
 import java.util.concurrent.SynchronousQueue
 import java.util.concurrent.ThreadPoolExecutor
@@ -23,16 +22,6 @@ internal class TorModule {
         private const val BACKOFF_BASE = 1.2
     }
 
-    @Provides
-    @Singleton
-    fun provideNetworkManager() = NetworkManager {
-        NetworkStatus(true, true, false)
-    }
-
-    @Provides
-    @Singleton
-    fun provideLocationUtils() = LocationUtils { "" }
-
     @Provides
     @Singleton
     fun provideCircumventionProvider(): CircumventionProvider = object : CircumventionProvider {
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
index d58d7afca829ed5a2b8438ff31275771783fbe6c..1ec14a2e1e37ef77ae933afd69a912a9eddec728 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
@@ -1,29 +1,11 @@
 package org.briarproject.mailbox.core.tor;
 
-import static net.freehaven.tor.control.TorControlCommands.HS_ADDRESS;
-import static net.freehaven.tor.control.TorControlCommands.HS_PRIVKEY;
-import static org.briarproject.mailbox.core.tor.TorConstants.CONTROL_PORT;
-import static org.briarproject.mailbox.core.tor.TorPlugin.State.ACTIVE;
-import static org.briarproject.mailbox.core.tor.TorPlugin.State.DISABLED;
-import static org.briarproject.mailbox.core.tor.TorPlugin.State.ENABLING;
-import static org.briarproject.mailbox.core.tor.TorPlugin.State.INACTIVE;
-import static org.briarproject.mailbox.core.tor.TorPlugin.State.STARTING_STOPPING;
-import static org.briarproject.mailbox.core.util.IoUtils.copyAndClose;
-import static org.briarproject.mailbox.core.util.IoUtils.tryToClose;
-import static org.briarproject.mailbox.core.util.LogUtils.info;
-import static org.briarproject.mailbox.core.util.LogUtils.logException;
-import static org.briarproject.mailbox.core.util.LogUtils.warn;
-import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
-import static org.slf4j.LoggerFactory.getLogger;
-import static java.util.Arrays.asList;
-import static java.util.Collections.singletonList;
-import static java.util.Collections.singletonMap;
-import static java.util.Objects.requireNonNull;
-
 import net.freehaven.tor.control.EventHandler;
 import net.freehaven.tor.control.TorControlConnection;
 
 import org.briarproject.mailbox.core.PoliteExecutor;
+import org.briarproject.mailbox.core.event.Event;
+import org.briarproject.mailbox.core.event.EventListener;
 import org.briarproject.mailbox.core.lifecycle.IoExecutor;
 import org.briarproject.mailbox.core.lifecycle.Service;
 import org.briarproject.mailbox.core.lifecycle.ServiceException;
@@ -55,7 +37,27 @@ import javax.annotation.Nullable;
 import javax.annotation.concurrent.GuardedBy;
 import javax.annotation.concurrent.ThreadSafe;
 
-abstract class TorPlugin implements Service, EventHandler {
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static java.util.Objects.requireNonNull;
+import static net.freehaven.tor.control.TorControlCommands.HS_ADDRESS;
+import static net.freehaven.tor.control.TorControlCommands.HS_PRIVKEY;
+import static org.briarproject.mailbox.core.tor.TorConstants.CONTROL_PORT;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.ACTIVE;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.DISABLED;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.ENABLING;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.INACTIVE;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.STARTING_STOPPING;
+import static org.briarproject.mailbox.core.util.IoUtils.copyAndClose;
+import static org.briarproject.mailbox.core.util.IoUtils.tryToClose;
+import static org.briarproject.mailbox.core.util.LogUtils.info;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.briarproject.mailbox.core.util.LogUtils.warn;
+import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
+import static org.slf4j.LoggerFactory.getLogger;
+
+abstract class TorPlugin implements Service, EventHandler, EventListener {
 
     private static final Logger LOG = getLogger(TorPlugin.class);
 
@@ -420,8 +422,7 @@ abstract class TorPlugin implements Service, EventHandler {
         info(LOG, () -> "OR connection " + status + " " + orName);
         if (status.equals("CLOSED") || status.equals("FAILED")) {
             // Check whether we've lost connectivity
-            updateConnectionStatus(networkManager.getNetworkStatus()
-            );
+            updateConnectionStatus(networkManager.getNetworkStatus());
         }
     }
 
@@ -449,8 +450,11 @@ abstract class TorPlugin implements Service, EventHandler {
         }
     }
 
-    public void onNetworkStatusChanged() {
-        updateConnectionStatus(networkManager.getNetworkStatus());
+    @Override
+    public void eventOccurred(Event e) {
+        if (e instanceof NetworkStatusEvent) {
+            updateConnectionStatus(((NetworkStatusEvent) e).getStatus());
+        }
     }
 
     private void disableNetwork() {
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
index 5c9befdefb6dda6dbdfd46dfeea83e547e1a0cf8..f0f70a70ecb71355a14a69833ded9f33c6e395af 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/IoUtils.java
@@ -1,9 +1,5 @@
 package org.briarproject.mailbox.core.util;
 
-import static org.briarproject.mailbox.core.util.LogUtils.logException;
-import static org.briarproject.mailbox.core.util.LogUtils.warn;
-import static org.slf4j.LoggerFactory.getLogger;
-
 import org.slf4j.Logger;
 
 import java.io.Closeable;
@@ -17,6 +13,10 @@ import java.net.Socket;
 
 import javax.annotation.Nullable;
 
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.briarproject.mailbox.core.util.LogUtils.warn;
+import static org.slf4j.LoggerFactory.getLogger;
+
 public class IoUtils {
 
     private static final Logger LOG = getLogger(IoUtils.class);
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/NetworkUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/NetworkUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..88b9306e8d692d0b5721832d6ff719da4f3649e6
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/NetworkUtils.java
@@ -0,0 +1,31 @@
+package org.briarproject.mailbox.core.util;
+
+import org.slf4j.Logger;
+
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.util.Enumeration;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.list;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.slf4j.LoggerFactory.getLogger;
+
+public class NetworkUtils {
+
+    private static final Logger LOG = getLogger(NetworkUtils.class.getName());
+
+    public static List<NetworkInterface> getNetworkInterfaces() {
+        try {
+            Enumeration<NetworkInterface> ifaces =
+                    NetworkInterface.getNetworkInterfaces();
+            // Despite what the docs say, the return value can be null
+            //noinspection ConstantConditions
+            return ifaces == null ? emptyList() : list(ifaces);
+        } catch (SocketException e) {
+            logException(LOG, e);
+            return emptyList();
+        }
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java
index 246d4d2c99e1f5b997bdfa839a3b281253ef7bc7..f0451e1bc53a1011cecc21bd2e6f9844cd14740e 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/PrivacyUtils.java
@@ -1,9 +1,5 @@
 package org.briarproject.mailbox.core.util;
 
-import static org.briarproject.mailbox.core.util.StringUtils.isNullOrEmpty;
-import static org.briarproject.mailbox.core.util.StringUtils.isValidMac;
-import static org.briarproject.mailbox.core.util.StringUtils.toHexString;
-
 import java.net.Inet4Address;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
@@ -11,6 +7,10 @@ import java.net.SocketAddress;
 
 import javax.annotation.Nullable;
 
+import static org.briarproject.mailbox.core.util.StringUtils.isNullOrEmpty;
+import static org.briarproject.mailbox.core.util.StringUtils.isValidMac;
+import static org.briarproject.mailbox.core.util.StringUtils.toHexString;
+
 public class PrivacyUtils {
 
 	public static String scrubOnion(String onion) {
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java
index 2a5cf7991ddecd9fa982300a824c78b03808fd75..5198a75d4c32a02ca46956351d8372754111a8ed 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/util/StringUtils.java
@@ -1,8 +1,5 @@
 package org.briarproject.mailbox.core.util;
 
-import static java.nio.charset.CodingErrorAction.IGNORE;
-import static java.util.regex.Pattern.CASE_INSENSITIVE;
-
 import java.io.UnsupportedEncodingException;
 import java.nio.ByteBuffer;
 import java.nio.charset.CharacterCodingException;
@@ -14,6 +11,9 @@ import java.util.regex.Pattern;
 
 import javax.annotation.Nullable;
 
+import static java.nio.charset.CodingErrorAction.IGNORE;
+import static java.util.regex.Pattern.CASE_INSENSITIVE;
+
 public class StringUtils {
 
 	private static final Charset UTF_8 = Charset.forName("UTF-8");