summaryrefslogtreecommitdiffhomepage
path: root/ui/src/main/java/com/wireguard
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2020-03-09 19:06:11 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2020-03-09 19:24:27 +0530
commit7d48bef70a56d4370856eedab619b1f83ac3d0d0 (patch)
tree76fd859578e499cd3a8fd2f402652530ea36a72d /ui/src/main/java/com/wireguard
parent6bc3e257f80a273d35d07099bd4ed99eb45163bf (diff)
Rename app module to ui
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'ui/src/main/java/com/wireguard')
-rw-r--r--ui/src/main/java/com/wireguard/android/Application.java171
-rw-r--r--ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java38
-rw-r--r--ui/src/main/java/com/wireguard/android/QuickTileService.java174
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/BaseActivity.java99
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/MainActivity.java144
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java129
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java47
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java34
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java51
-rw-r--r--ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java67
-rw-r--r--ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java103
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java148
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java140
-rw-r--r--ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java159
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt106
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java140
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java126
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java118
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java162
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java264
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java449
-rw-r--r--ui/src/main/java/com/wireguard/android/model/ApplicationData.java54
-rw-r--r--ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java142
-rw-r--r--ui/src/main/java/com/wireguard/android/model/TunnelManager.java301
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java112
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java92
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java102
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/VersionPreference.java71
-rw-r--r--ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java119
-rw-r--r--ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt67
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java37
-rw-r--r--ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java98
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ErrorMessages.java160
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java36
-rw-r--r--ui/src/main/java/com/wireguard/android/util/Extensions.kt16
-rw-r--r--ui/src/main/java/com/wireguard/android/util/FragmentUtils.java27
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ModuleLoader.java186
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java109
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java19
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java198
-rw-r--r--ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java17
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java93
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java190
-rw-r--r--ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java380
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java54
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java59
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java53
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java217
-rw-r--r--ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java59
-rw-r--r--ui/src/main/java/com/wireguard/util/Keyed.java14
-rw-r--r--ui/src/main/java/com/wireguard/util/KeyedList.java32
-rw-r--r--ui/src/main/java/com/wireguard/util/SortedKeyedList.java31
52 files changed, 6014 insertions, 0 deletions
diff --git a/ui/src/main/java/com/wireguard/android/Application.java b/ui/src/main/java/com/wireguard/android/Application.java
new file mode 100644
index 00000000..2ebeb69d
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/Application.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.StrictMode;
+import android.util.Log;
+
+import androidx.preference.PreferenceManager;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDelegate;
+
+import com.wireguard.android.backend.Backend;
+import com.wireguard.android.backend.GoBackend;
+import com.wireguard.android.backend.WgQuickBackend;
+import com.wireguard.android.configStore.FileConfigStore;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.util.AsyncWorker;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.android.util.ModuleLoader;
+import com.wireguard.android.util.RootShell;
+import com.wireguard.android.util.ToolsInstaller;
+
+import java.lang.ref.WeakReference;
+import java.util.Locale;
+
+import java9.util.concurrent.CompletableFuture;
+
+public class Application extends android.app.Application {
+ private static final String TAG = "WireGuard/" + Application.class.getSimpleName();
+ public static final String USER_AGENT;
+
+ static {
+ String preferredAbi = "unknown ABI";
+ if (Build.SUPPORTED_ABIS.length > 0)
+ preferredAbi = Build.SUPPORTED_ABIS[0];
+ USER_AGENT = String.format(Locale.ENGLISH, "WireGuard/%s (Android %d; %s; %s; %s %s; %s)", BuildConfig.VERSION_NAME, Build.VERSION.SDK_INT, preferredAbi, Build.BOARD, Build.MANUFACTURER, Build.MODEL, Build.FINGERPRINT);
+ }
+
+ @SuppressWarnings("NullableProblems") private static WeakReference<Application> weakSelf;
+ private final CompletableFuture<Backend> futureBackend = new CompletableFuture<>();
+ @SuppressWarnings("NullableProblems") private AsyncWorker asyncWorker;
+ @Nullable private Backend backend;
+ @SuppressWarnings("NullableProblems") private RootShell rootShell;
+ @SuppressWarnings("NullableProblems") private SharedPreferences sharedPreferences;
+ @SuppressWarnings("NullableProblems") private ToolsInstaller toolsInstaller;
+ @SuppressWarnings("NullableProblems") private ModuleLoader moduleLoader;
+ @SuppressWarnings("NullableProblems") private TunnelManager tunnelManager;
+
+ public Application() {
+ weakSelf = new WeakReference<>(this);
+ }
+
+ public static Application get() {
+ return weakSelf.get();
+ }
+
+ public static AsyncWorker getAsyncWorker() {
+ return get().asyncWorker;
+ }
+
+ public static Backend getBackend() {
+ final Application app = get();
+ synchronized (app.futureBackend) {
+ if (app.backend == null) {
+ Backend backend = null;
+ boolean didStartRootShell = false;
+ if (!ModuleLoader.isModuleLoaded() && app.moduleLoader.moduleMightExist()) {
+ try {
+ app.rootShell.start();
+ didStartRootShell = true;
+ app.moduleLoader.loadModule();
+ } catch (final Exception ignored) {
+ }
+ }
+ if (ModuleLoader.isModuleLoaded()) {
+ try {
+ if (!didStartRootShell)
+ app.rootShell.start();
+ backend = new WgQuickBackend(app.getApplicationContext(), app.rootShell, app.toolsInstaller);
+ } catch (final Exception ignored) {
+ }
+ }
+ if (backend == null) {
+ backend = new GoBackend(app.getApplicationContext());
+ GoBackend.setAlwaysOnCallback(() -> {
+ get().tunnelManager.restoreState(true).whenComplete(ExceptionLoggers.D);
+ });
+ }
+ app.backend = backend;
+ }
+ return app.backend;
+ }
+ }
+
+ public static CompletableFuture<Backend> getBackendAsync() {
+ return get().futureBackend;
+ }
+
+ public static RootShell getRootShell() {
+ return get().rootShell;
+ }
+
+ public static SharedPreferences getSharedPreferences() {
+ return get().sharedPreferences;
+ }
+
+ public static ToolsInstaller getToolsInstaller() {
+ return get().toolsInstaller;
+ }
+
+ public static ModuleLoader getModuleLoader() {
+ return get().moduleLoader;
+ }
+
+ public static TunnelManager getTunnelManager() {
+ return get().tunnelManager;
+ }
+
+ @Override
+ protected void attachBaseContext(final Context context) {
+ super.attachBaseContext(context);
+
+ if (BuildConfig.MIN_SDK_VERSION > Build.VERSION.SDK_INT) {
+ final Intent intent = new Intent(Intent.ACTION_MAIN);
+ intent.addCategory(Intent.CATEGORY_HOME);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ System.exit(0);
+ }
+
+ if (BuildConfig.DEBUG) {
+ StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build());
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ Log.i(TAG, USER_AGENT);
+ super.onCreate();
+
+ asyncWorker = new AsyncWorker(AsyncTask.SERIAL_EXECUTOR, new Handler(Looper.getMainLooper()));
+ rootShell = new RootShell(getApplicationContext());
+ toolsInstaller = new ToolsInstaller(getApplicationContext(), rootShell);
+ moduleLoader = new ModuleLoader(getApplicationContext(), rootShell, USER_AGENT);
+
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ AppCompatDelegate.setDefaultNightMode(
+ sharedPreferences.getBoolean("dark_theme", false) ?
+ AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO);
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
+ }
+
+ tunnelManager = new TunnelManager(new FileConfigStore(getApplicationContext()));
+ tunnelManager.onCreate();
+
+ asyncWorker.supplyAsync(Application::getBackend).thenAccept(futureBackend::complete);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java
new file mode 100644
index 00000000..e3ffce7a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/BootShutdownReceiver.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.wireguard.android.backend.WgQuickBackend;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.util.ExceptionLoggers;
+
+public class BootShutdownReceiver extends BroadcastReceiver {
+ private static final String TAG = "WireGuard/" + BootShutdownReceiver.class.getSimpleName();
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ Application.getBackendAsync().thenAccept(backend -> {
+ if (!(backend instanceof WgQuickBackend))
+ return;
+ final String action = intent.getAction();
+ if (action == null)
+ return;
+ final TunnelManager tunnelManager = Application.getTunnelManager();
+ if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
+ Log.i(TAG, "Broadcast receiver restoring state (boot)");
+ tunnelManager.restoreState(false).whenComplete(ExceptionLoggers.D);
+ } else if (Intent.ACTION_SHUTDOWN.equals(action)) {
+ Log.i(TAG, "Broadcast receiver saving state (shutdown)");
+ tunnelManager.saveState();
+ }
+ });
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/QuickTileService.java b/ui/src/main/java/com/wireguard/android/QuickTileService.java
new file mode 100644
index 00000000..66aecec3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/QuickTileService.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android;
+
+import android.content.Intent;
+import androidx.databinding.Observable;
+import androidx.databinding.Observable.OnPropertyChangedCallback;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.Icon;
+import android.os.Build;
+import android.os.IBinder;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import android.util.Log;
+
+import com.wireguard.android.activity.MainActivity;
+import com.wireguard.android.activity.TunnelToggleActivity;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.widget.SlashDrawable;
+
+import java.util.Objects;
+
+/**
+ * Service that maintains the application's custom Quick Settings tile. This service is bound by the
+ * system framework as necessary to update the appearance of the tile in the system UI, and to
+ * forward click events to the application.
+ */
+
+@RequiresApi(Build.VERSION_CODES.N)
+public class QuickTileService extends TileService {
+ private static final String TAG = "WireGuard/" + QuickTileService.class.getSimpleName();
+
+ private final OnStateChangedCallback onStateChangedCallback = new OnStateChangedCallback();
+ private final OnTunnelChangedCallback onTunnelChangedCallback = new OnTunnelChangedCallback();
+ @Nullable private Icon iconOff;
+ @Nullable private Icon iconOn;
+ @Nullable private ObservableTunnel tunnel;
+
+ /* This works around an annoying unsolved frameworks bug some people are hitting. */
+ @Override
+ @Nullable
+ public IBinder onBind(final Intent intent) {
+ IBinder ret = null;
+ try {
+ ret = super.onBind(intent);
+ } catch (final Exception e) {
+ Log.d(TAG, "Failed to bind to TileService", e);
+ }
+ return ret;
+ }
+
+ @Override
+ public void onClick() {
+ if (tunnel != null) {
+ unlockAndRun(() -> {
+ final Tile tile = getQsTile();
+ if (tile != null) {
+ tile.setIcon(tile.getIcon() == iconOn ? iconOff : iconOn);
+ tile.updateTile();
+ }
+ tunnel.setState(State.TOGGLE).whenComplete((v, t) -> {
+ if (t == null) {
+ updateTile();
+ } else {
+ final Intent toggleIntent = new Intent(this, TunnelToggleActivity.class);
+ toggleIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(toggleIntent);
+ }
+ });
+ });
+ } else {
+ final Intent intent = new Intent(this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivityAndCollapse(intent);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ iconOff = iconOn = Icon.createWithResource(this, R.drawable.ic_tile);
+ return;
+ }
+ final SlashDrawable icon = new SlashDrawable(getResources().getDrawable(R.drawable.ic_tile, Application.get().getTheme()));
+ icon.setAnimationEnabled(false); /* Unfortunately we can't have animations, since Icons are marshaled. */
+ icon.setSlashed(false);
+ Bitmap b = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ Canvas c = new Canvas(b);
+ icon.setBounds(0, 0, c.getWidth(), c.getHeight());
+ icon.draw(c);
+ iconOn = Icon.createWithBitmap(b);
+ icon.setSlashed(true);
+ b = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ c = new Canvas(b);
+ icon.setBounds(0, 0, c.getWidth(), c.getHeight());
+ icon.draw(c);
+ iconOff = Icon.createWithBitmap(b);
+ }
+
+ @Override
+ public void onStartListening() {
+ Application.getTunnelManager().addOnPropertyChangedCallback(onTunnelChangedCallback);
+ if (tunnel != null)
+ tunnel.addOnPropertyChangedCallback(onStateChangedCallback);
+ updateTile();
+ }
+
+ @Override
+ public void onStopListening() {
+ if (tunnel != null)
+ tunnel.removeOnPropertyChangedCallback(onStateChangedCallback);
+ Application.getTunnelManager().removeOnPropertyChangedCallback(onTunnelChangedCallback);
+ }
+
+ private void updateTile() {
+ // Update the tunnel.
+ final ObservableTunnel newTunnel = Application.getTunnelManager().getLastUsedTunnel();
+ if (newTunnel != tunnel) {
+ if (tunnel != null)
+ tunnel.removeOnPropertyChangedCallback(onStateChangedCallback);
+ tunnel = newTunnel;
+ if (tunnel != null)
+ tunnel.addOnPropertyChangedCallback(onStateChangedCallback);
+ }
+ // Update the tile contents.
+ final String label;
+ final int state;
+ final Tile tile = getQsTile();
+ if (tunnel != null) {
+ label = tunnel.getName();
+ state = tunnel.getState() == State.UP ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE;
+ } else {
+ label = getString(R.string.app_name);
+ state = Tile.STATE_INACTIVE;
+ }
+ if (tile == null)
+ return;
+ tile.setLabel(label);
+ if (tile.getState() != state) {
+ tile.setIcon(state == Tile.STATE_ACTIVE ? iconOn : iconOff);
+ tile.setState(state);
+ }
+ tile.updateTile();
+ }
+
+ private final class OnStateChangedCallback extends OnPropertyChangedCallback {
+ @Override
+ public void onPropertyChanged(final Observable sender, final int propertyId) {
+ if (!Objects.equals(sender, tunnel)) {
+ sender.removeOnPropertyChangedCallback(this);
+ return;
+ }
+ if (propertyId != 0 && propertyId != BR.state)
+ return;
+ updateTile();
+ }
+ }
+
+ private final class OnTunnelChangedCallback extends OnPropertyChangedCallback {
+ @Override
+ public void onPropertyChanged(final Observable sender, final int propertyId) {
+ if (propertyId != 0 && propertyId != BR.lastUsedTunnel)
+ return;
+ updateTile();
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java
new file mode 100644
index 00000000..8ec58ee8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import androidx.databinding.CallbackRegistry;
+import androidx.databinding.CallbackRegistry.NotifierCallback;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.model.ObservableTunnel;
+
+import java.util.Objects;
+
+/**
+ * Base class for activities that need to remember the currently-selected tunnel.
+ */
+
+public abstract class BaseActivity extends ThemeChangeAwareActivity {
+ private static final String KEY_SELECTED_TUNNEL = "selected_tunnel";
+
+ private final SelectionChangeRegistry selectionChangeRegistry = new SelectionChangeRegistry();
+ @Nullable private ObservableTunnel selectedTunnel;
+
+ public void addOnSelectedTunnelChangedListener(final OnSelectedTunnelChangedListener listener) {
+ selectionChangeRegistry.add(listener);
+ }
+
+ @Nullable
+ public ObservableTunnel getSelectedTunnel() {
+ return selectedTunnel;
+ }
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ // Restore the saved tunnel if there is one; otherwise grab it from the arguments.
+ final String savedTunnelName;
+ if (savedInstanceState != null)
+ savedTunnelName = savedInstanceState.getString(KEY_SELECTED_TUNNEL);
+ else if (getIntent() != null)
+ savedTunnelName = getIntent().getStringExtra(KEY_SELECTED_TUNNEL);
+ else
+ savedTunnelName = null;
+
+ if (savedTunnelName != null)
+ Application.getTunnelManager().getTunnels()
+ .thenAccept(tunnels -> setSelectedTunnel(tunnels.get(savedTunnelName)));
+
+ // The selected tunnel must be set before the superclass method recreates fragments.
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onSaveInstanceState(final Bundle outState) {
+ if (selectedTunnel != null)
+ outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel.getName());
+ super.onSaveInstanceState(outState);
+ }
+
+ protected abstract void onSelectedTunnelChanged(@Nullable ObservableTunnel oldTunnel, @Nullable ObservableTunnel newTunnel);
+
+ public void removeOnSelectedTunnelChangedListener(
+ final OnSelectedTunnelChangedListener listener) {
+ selectionChangeRegistry.remove(listener);
+ }
+
+ public void setSelectedTunnel(@Nullable final ObservableTunnel tunnel) {
+ final ObservableTunnel oldTunnel = selectedTunnel;
+ if (Objects.equals(oldTunnel, tunnel))
+ return;
+ selectedTunnel = tunnel;
+ onSelectedTunnelChanged(oldTunnel, tunnel);
+ selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, tunnel);
+ }
+
+ public interface OnSelectedTunnelChangedListener {
+ void onSelectedTunnelChanged(@Nullable ObservableTunnel oldTunnel, @Nullable ObservableTunnel newTunnel);
+ }
+
+ private static final class SelectionChangeNotifier
+ extends NotifierCallback<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel> {
+ @Override
+ public void onNotifyCallback(final OnSelectedTunnelChangedListener listener,
+ final ObservableTunnel oldTunnel, final int ignored,
+ final ObservableTunnel newTunnel) {
+ listener.onSelectedTunnelChanged(oldTunnel, newTunnel);
+ }
+ }
+
+ private static final class SelectionChangeRegistry
+ extends CallbackRegistry<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel> {
+ private SelectionChangeRegistry() {
+ super(new SelectionChangeNotifier());
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.java b/ui/src/main/java/com/wireguard/android/activity/MainActivity.java
new file mode 100644
index 00000000..4c33f000
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.appcompat.app.ActionBar;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View.OnApplyWindowInsetsListener;
+import android.widget.LinearLayout;
+
+import com.wireguard.android.R;
+import com.wireguard.android.fragment.TunnelDetailFragment;
+import com.wireguard.android.fragment.TunnelEditorFragment;
+import com.wireguard.android.model.ObservableTunnel;
+
+import java.util.List;
+
+/**
+ * CRUD interface for WireGuard tunnels. This activity serves as the main entry point to the
+ * WireGuard application, and contains several fragments for listing, viewing details of, and
+ * editing the configuration and interface state of WireGuard tunnels.
+ */
+
+public class MainActivity extends BaseActivity
+ implements FragmentManager.OnBackStackChangedListener {
+ @Nullable private ActionBar actionBar;
+ private boolean isTwoPaneLayout;
+
+ @Override
+ public void onBackPressed() {
+ final int backStackEntries = getSupportFragmentManager().getBackStackEntryCount();
+ // If the two-pane layout does not have an editor open, going back should exit the app.
+ if (isTwoPaneLayout && backStackEntries <= 1) {
+ finish();
+ return;
+ }
+ // Deselect the current tunnel on navigating back from the detail pane to the one-pane list.
+ if (!isTwoPaneLayout && backStackEntries == 1) {
+ getSupportFragmentManager().popBackStack();
+ setSelectedTunnel(null);
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ @Override public void onBackStackChanged() {
+ if (actionBar == null)
+ return;
+ // Do not show the home menu when the two-pane layout is at the detail view (see above).
+ final int backStackEntries = getSupportFragmentManager().getBackStackEntryCount();
+ final int minBackStackEntries = isTwoPaneLayout ? 2 : 1;
+ actionBar.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries);
+ }
+
+ // We use onTouchListener here to avoid the UI click sound, hence
+ // calling View#performClick defeats the purpose of it.
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main_activity);
+ actionBar = getSupportActionBar();
+ isTwoPaneLayout = findViewById(R.id.master_detail_wrapper) instanceof LinearLayout;
+ getSupportFragmentManager().addOnBackStackChangedListener(this);
+ onBackStackChanged();
+ // Dispatch insets on back stack change
+ // This is required to ensure replaced fragments are also able to consume insets
+ findViewById(R.id.master_detail_wrapper).setOnApplyWindowInsetsListener((OnApplyWindowInsetsListener) (v, insets) -> {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ fragmentManager.addOnBackStackChangedListener(() -> {
+ final List<Fragment> fragments = fragmentManager.getFragments();
+ for (int i = 0; i < fragments.size(); i++) {
+ fragments.get(i).requireView().dispatchApplyWindowInsets(insets);
+ }
+ });
+ return insets;
+ });
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ getMenuInflater().inflate(R.menu.main_activity, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // The back arrow in the action bar should act the same as the back button.
+ onBackPressed();
+ return true;
+ case R.id.menu_action_edit:
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.detail_container, new TunnelEditorFragment())
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ .addToBackStack(null)
+ .commit();
+ return true;
+ case R.id.menu_action_save:
+ // This menu item is handled by the editor fragment.
+ return false;
+ case R.id.menu_settings:
+ startActivity(new Intent(this, SettingsActivity.class));
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ protected void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel,
+ @Nullable final ObservableTunnel newTunnel) {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ final int backStackEntries = fragmentManager.getBackStackEntryCount();
+ if (newTunnel == null) {
+ // Clear everything off the back stack (all editors and detail fragments).
+ fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ return;
+ }
+ if (backStackEntries == 2) {
+ // Pop the editor off the back stack to reveal the detail fragment. Use the immediate
+ // method to avoid the editor picking up the new tunnel while it is still visible.
+ fragmentManager.popBackStackImmediate();
+ } else if (backStackEntries == 0) {
+ // Create and show a new detail fragment.
+ fragmentManager.beginTransaction()
+ .add(R.id.detail_container, new TunnelDetailFragment())
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
+ .addToBackStack(null)
+ .commit();
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java
new file mode 100644
index 00000000..f545c371
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+import android.util.SparseArray;
+import android.view.MenuItem;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.backend.WgQuickBackend;
+import com.wireguard.android.util.ModuleLoader;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Interface for changing application-global persistent settings.
+ */
+
+public class SettingsActivity extends ThemeChangeAwareActivity {
+ private final SparseArray<PermissionRequestCallback> permissionRequestCallbacks = new SparseArray<>();
+ private int permissionRequestCounter;
+
+ public void ensurePermissions(final String[] permissions, final PermissionRequestCallback cb) {
+ final List<String> needPermissions = new ArrayList<>(permissions.length);
+ for (final String permission : permissions) {
+ if (ContextCompat.checkSelfPermission(this, permission)
+ != PackageManager.PERMISSION_GRANTED)
+ needPermissions.add(permission);
+ }
+ if (needPermissions.isEmpty()) {
+ final int[] granted = new int[permissions.length];
+ Arrays.fill(granted, PackageManager.PERMISSION_GRANTED);
+ cb.done(permissions, granted);
+ return;
+ }
+ final int idx = permissionRequestCounter++;
+ permissionRequestCallbacks.put(idx, cb);
+ ActivityCompat.requestPermissions(this,
+ needPermissions.toArray(new String[needPermissions.size()]), idx);
+ }
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) {
+ getSupportFragmentManager().beginTransaction()
+ .add(android.R.id.content, new SettingsFragment())
+ .commit();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(final int requestCode,
+ final String[] permissions,
+ final int[] grantResults) {
+ final PermissionRequestCallback f = permissionRequestCallbacks.get(requestCode);
+ if (f != null) {
+ permissionRequestCallbacks.remove(requestCode);
+ f.done(permissions, grantResults);
+ }
+ }
+
+ public interface PermissionRequestCallback {
+ void done(String[] permissions, int[] grantResults);
+ }
+
+ public static class SettingsFragment extends PreferenceFragmentCompat {
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String key) {
+ addPreferencesFromResource(R.xml.preferences);
+ final PreferenceScreen screen = getPreferenceScreen();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ screen.removePreference(getPreferenceManager().findPreference("dark_theme"));
+
+ final Preference wgQuickOnlyPrefs[] = {
+ getPreferenceManager().findPreference("tools_installer"),
+ getPreferenceManager().findPreference("restore_on_boot")
+ };
+ for (final Preference pref : wgQuickOnlyPrefs)
+ pref.setVisible(false);
+ Application.getBackendAsync().thenAccept(backend -> {
+ for (final Preference pref : wgQuickOnlyPrefs) {
+ if (backend instanceof WgQuickBackend)
+ pref.setVisible(true);
+ else
+ screen.removePreference(pref);
+ }
+ });
+
+ final Preference moduleInstaller = getPreferenceManager().findPreference("module_downloader");
+ moduleInstaller.setVisible(false);
+ if (ModuleLoader.isModuleLoaded()) {
+ screen.removePreference(moduleInstaller);
+ } else {
+ Application.getAsyncWorker().runAsync(Application.getRootShell()::start).whenComplete((v, e) -> {
+ if (e == null)
+ moduleInstaller.setVisible(true);
+ else
+ screen.removePreference(moduleInstaller);
+ });
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java b/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java
new file mode 100644
index 00000000..602ad37c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/ThemeChangeAwareActivity.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import android.util.Log;
+
+import com.wireguard.android.Application;
+
+import java.lang.reflect.Field;
+
+public abstract class ThemeChangeAwareActivity extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String TAG = "WireGuard/" + ThemeChangeAwareActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+ Application.getSharedPreferences().registerOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
+ Application.getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
+ super.onDestroy();
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) {
+ if ("dark_theme".equals(key)) {
+ AppCompatDelegate.setDefaultNightMode(
+ sharedPreferences.getBoolean(key, false) ?
+ AppCompatDelegate.MODE_NIGHT_YES :
+ AppCompatDelegate.MODE_NIGHT_NO);
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java
new file mode 100644
index 00000000..c87ec537
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.fragment.TunnelEditorFragment;
+import com.wireguard.android.model.ObservableTunnel;
+
+/**
+ * Standalone activity for creating tunnels.
+ */
+
+public class TunnelCreatorActivity extends BaseActivity {
+ @Override
+ @SuppressWarnings("UnnecessaryFullyQualifiedName")
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) {
+ getSupportFragmentManager().beginTransaction()
+ .add(android.R.id.content, new TunnelEditorFragment())
+ .commit();
+ }
+ }
+
+ @Override
+ protected void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ finish();
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java
new file mode 100644
index 00000000..09a34bf7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AppCompatActivity;
+
+import android.content.ComponentName;
+import android.os.Bundle;
+import android.os.Build;
+import android.service.quicksettings.TileService;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.QuickTileService;
+import com.wireguard.android.R;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.util.ErrorMessages;
+
+@RequiresApi(Build.VERSION_CODES.N)
+public class TunnelToggleActivity extends AppCompatActivity {
+ private static final String TAG = "WireGuard/" + TunnelToggleActivity.class.getSimpleName();
+
+ @Override
+ protected void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final ObservableTunnel tunnel = Application.getTunnelManager().getLastUsedTunnel();
+ if (tunnel == null)
+ return;
+ tunnel.setState(State.TOGGLE).whenComplete((v, t) -> {
+ TileService.requestListeningState(this, new ComponentName(this, QuickTileService.class));
+ onToggleFinished(t);
+ finishAffinity();
+ });
+ }
+
+ private void onToggleFinished(@Nullable final Throwable throwable) {
+ if (throwable == null)
+ return;
+ final String error = ErrorMessages.get(throwable);
+ final String message = getString(R.string.toggle_error, error);
+ Log.e(TAG, message, throwable);
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java
new file mode 100644
index 00000000..d4761464
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/ConfigStore.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.configStore;
+
+import com.wireguard.config.Config;
+
+import java.util.Set;
+
+/**
+ * Interface for persistent storage providers for WireGuard configurations.
+ */
+
+public interface ConfigStore {
+ /**
+ * Create a persistent tunnel, which must have a unique name within the persistent storage
+ * medium.
+ *
+ * @param name The name of the tunnel to create.
+ * @param config Configuration for the new tunnel.
+ * @return The configuration that was actually saved to persistent storage.
+ */
+ Config create(final String name, final Config config) throws Exception;
+
+ /**
+ * Delete a persistent tunnel.
+ *
+ * @param name The name of the tunnel to delete.
+ */
+ void delete(final String name) throws Exception;
+
+ /**
+ * Enumerate the names of tunnels present in persistent storage.
+ *
+ * @return The set of present tunnel names.
+ */
+ Set<String> enumerate();
+
+ /**
+ * Load the configuration for the tunnel given by {@code name}.
+ *
+ * @param name The identifier for the configuration in persistent storage (i.e. the name of the
+ * tunnel).
+ * @return An in-memory representation of the configuration loaded from persistent storage.
+ */
+ Config load(final String name) throws Exception;
+
+ /**
+ * Rename the configuration for the tunnel given by {@code name}.
+ *
+ * @param name The identifier for the existing configuration in persistent storage.
+ * @param replacement The new identifier for the configuration in persistent storage.
+ */
+ void rename(String name, String replacement) throws Exception;
+
+ /**
+ * Save the configuration for an existing tunnel given by {@code name}.
+ *
+ * @param name The identifier for the configuration in persistent storage (i.e. the name of
+ * the tunnel).
+ * @param config An updated configuration object for the tunnel.
+ * @return The configuration that was actually saved to persistent storage.
+ */
+ Config save(final String name, final Config config) throws Exception;
+}
diff --git a/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java
new file mode 100644
index 00000000..45f2f759
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/configStore/FileConfigStore.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.configStore;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.wireguard.android.R;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+
+import java9.util.stream.Collectors;
+import java9.util.stream.Stream;
+
+/**
+ * Configuration store that uses a {@code wg-quick}-style file for each configured tunnel.
+ */
+
+public final class FileConfigStore implements ConfigStore {
+ private static final String TAG = "WireGuard/" + FileConfigStore.class.getSimpleName();
+
+ private final Context context;
+
+ public FileConfigStore(final Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public Config create(final String name, final Config config) throws IOException {
+ Log.d(TAG, "Creating configuration for tunnel " + name);
+ final File file = fileFor(name);
+ if (!file.createNewFile())
+ throw new IOException(context.getString(R.string.config_file_exists_error, file.getName()));
+ try (final FileOutputStream stream = new FileOutputStream(file, false)) {
+ stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8));
+ }
+ return config;
+ }
+
+ @Override
+ public void delete(final String name) throws IOException {
+ Log.d(TAG, "Deleting configuration for tunnel " + name);
+ final File file = fileFor(name);
+ if (!file.delete())
+ throw new IOException(context.getString(R.string.config_delete_error, file.getName()));
+ }
+
+ @Override
+ public Set<String> enumerate() {
+ return Stream.of(context.fileList())
+ .filter(name -> name.endsWith(".conf"))
+ .map(name -> name.substring(0, name.length() - ".conf".length()))
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ private File fileFor(final String name) {
+ return new File(context.getFilesDir(), name + ".conf");
+ }
+
+ @Override
+ public Config load(final String name) throws BadConfigException, IOException {
+ try (final FileInputStream stream = new FileInputStream(fileFor(name))) {
+ return Config.parse(stream);
+ }
+ }
+
+ @Override
+ public void rename(final String name, final String replacement) throws IOException {
+ Log.d(TAG, "Renaming configuration for tunnel " + name + " to " + replacement);
+ final File file = fileFor(name);
+ final File replacementFile = fileFor(replacement);
+ if (!replacementFile.createNewFile())
+ throw new IOException(context.getString(R.string.config_exists_error, replacement));
+ if (!file.renameTo(replacementFile)) {
+ if (!replacementFile.delete())
+ Log.w(TAG, "Couldn't delete marker file for new name " + replacement);
+ throw new IOException(context.getString(R.string.config_rename_error, file.getName()));
+ }
+ }
+
+ @Override
+ public Config save(final String name, final Config config) throws IOException {
+ Log.d(TAG, "Saving configuration for tunnel " + name);
+ final File file = fileFor(name);
+ if (!file.isFile())
+ throw new FileNotFoundException(context.getString(R.string.config_not_found_error, file.getName()));
+ try (final FileOutputStream stream = new FileOutputStream(file, false)) {
+ stream.write(config.toWgQuickString().getBytes(StandardCharsets.UTF_8));
+ }
+ return config;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
new file mode 100644
index 00000000..ee216d4c
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.databinding;
+
+import androidx.databinding.BindingAdapter;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ObservableList;
+import androidx.databinding.ViewDataBinding;
+import androidx.databinding.adapters.ListenerUtil;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import android.text.InputFilter;
+import android.view.LayoutInflater;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler;
+import com.wireguard.android.util.ObservableKeyedList;
+import com.wireguard.android.widget.ToggleSwitch;
+import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener;
+import com.wireguard.config.Attribute;
+import com.wireguard.config.InetNetwork;
+import com.wireguard.util.Keyed;
+
+import java9.util.Optional;
+
+/**
+ * Static methods for use by generated code in the Android data binding library.
+ */
+
+@SuppressWarnings("unused")
+public final class BindingAdapters {
+ private BindingAdapters() {
+ // Prevent instantiation.
+ }
+
+ @BindingAdapter("checked")
+ public static void setChecked(final ToggleSwitch view, final boolean checked) {
+ view.setCheckedInternal(checked);
+ }
+
+ @BindingAdapter("filter")
+ public static void setFilter(final TextView view, final InputFilter filter) {
+ view.setFilters(new InputFilter[]{filter});
+ }
+
+ @BindingAdapter({"items", "layout"})
+ public static <E>
+ void setItems(final LinearLayout view,
+ @Nullable final ObservableList<E> oldList, final int oldLayoutId,
+ @Nullable final ObservableList<E> newList, final int newLayoutId) {
+ if (oldList == newList && oldLayoutId == newLayoutId)
+ return;
+ ItemChangeListener<E> listener = ListenerUtil.getListener(view, R.id.item_change_listener);
+ // If the layout changes, any existing listener must be replaced.
+ if (listener != null && oldList != null && oldLayoutId != newLayoutId) {
+ listener.setList(null);
+ listener = null;
+ // Stop tracking the old listener.
+ ListenerUtil.trackListener(view, null, R.id.item_change_listener);
+ }
+ // Avoid adding a listener when there is no new list or layout.
+ if (newList == null || newLayoutId == 0)
+ return;
+ if (listener == null) {
+ listener = new ItemChangeListener<>(view, newLayoutId);
+ ListenerUtil.trackListener(view, listener, R.id.item_change_listener);
+ }
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ listener.setList(newList);
+ }
+
+ @BindingAdapter({"items", "layout"})
+ public static <E>
+ void setItems(final LinearLayout view,
+ @Nullable final Iterable<E> oldList, final int oldLayoutId,
+ @Nullable final Iterable<E> newList, final int newLayoutId) {
+ if (oldList == newList && oldLayoutId == newLayoutId)
+ return;
+ view.removeAllViews();
+ if (newList == null)
+ return;
+ final LayoutInflater layoutInflater = LayoutInflater.from(view.getContext());
+ for (final E item : newList) {
+ final ViewDataBinding binding =
+ DataBindingUtil.inflate(layoutInflater, newLayoutId, view, false);
+ binding.setVariable(BR.collection, newList);
+ binding.setVariable(BR.item, item);
+ binding.executePendingBindings();
+ view.addView(binding.getRoot());
+ }
+ }
+
+ @BindingAdapter(requireAll = false, value = {"items", "layout", "configurationHandler"})
+ public static <K, E extends Keyed<? extends K>>
+ void setItems(final RecyclerView view,
+ @Nullable final ObservableKeyedList<K, E> oldList, final int oldLayoutId,
+ final RowConfigurationHandler oldRowConfigurationHandler,
+ @Nullable final ObservableKeyedList<K, E> newList, final int newLayoutId,
+ final RowConfigurationHandler newRowConfigurationHandler) {
+ if (view.getLayoutManager() == null)
+ view.setLayoutManager(new LinearLayoutManager(view.getContext(), RecyclerView.VERTICAL, false));
+
+ if (oldList == newList && oldLayoutId == newLayoutId)
+ return;
+ // The ListAdapter interface is not generic, so this cannot be checked.
+ @SuppressWarnings("unchecked") ObservableKeyedRecyclerViewAdapter<K, E> adapter =
+ (ObservableKeyedRecyclerViewAdapter<K, E>) view.getAdapter();
+ // If the layout changes, any existing adapter must be replaced.
+ if (adapter != null && oldList != null && oldLayoutId != newLayoutId) {
+ adapter.setList(null);
+ adapter = null;
+ }
+ // Avoid setting an adapter when there is no new list or layout.
+ if (newList == null || newLayoutId == 0)
+ return;
+ if (adapter == null) {
+ adapter = new ObservableKeyedRecyclerViewAdapter<>(view.getContext(), newLayoutId, newList);
+ view.setAdapter(adapter);
+ }
+
+ adapter.setRowConfigurationHandler(newRowConfigurationHandler);
+ // Either the list changed, or this is an entirely new listener because the layout changed.
+ adapter.setList(newList);
+ }
+
+ @BindingAdapter("onBeforeCheckedChanged")
+ public static void setOnBeforeCheckedChanged(final ToggleSwitch view,
+ final OnBeforeCheckedChangeListener listener) {
+ view.setOnBeforeCheckedChangeListener(listener);
+ }
+
+ @BindingAdapter("android:text")
+ public static void setText(final TextView view, final Optional<?> text) {
+ view.setText(text.map(Object::toString).orElse(""));
+ }
+
+ @BindingAdapter("android:text")
+ public static void setText(final TextView view, @Nullable final Iterable<InetNetwork> networks) {
+ view.setText(networks != null ? Attribute.join(networks) : "");
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
new file mode 100644
index 00000000..e7303eae
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.databinding;
+
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ObservableList;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.wireguard.android.BR;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+/**
+ * Helper class for binding an ObservableList to the children of a ViewGroup.
+ */
+
+class ItemChangeListener<T> {
+ private final OnListChangedCallback<T> callback = new OnListChangedCallback<>(this);
+ private final ViewGroup container;
+ private final int layoutId;
+ private final LayoutInflater layoutInflater;
+ @Nullable private ObservableList<T> list;
+
+ ItemChangeListener(final ViewGroup container, final int layoutId) {
+ this.container = container;
+ this.layoutId = layoutId;
+ layoutInflater = LayoutInflater.from(container.getContext());
+ }
+
+ private View getView(final int position, @Nullable final View convertView) {
+ ViewDataBinding binding = convertView != null ? DataBindingUtil.getBinding(convertView) : null;
+ if (binding == null) {
+ binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false);
+ }
+
+ Objects.requireNonNull(list, "Trying to get a view while list is still null");
+
+ binding.setVariable(BR.collection, list);
+ binding.setVariable(BR.item, list.get(position));
+ binding.executePendingBindings();
+ return binding.getRoot();
+ }
+
+ void setList(@Nullable final ObservableList<T> newList) {
+ if (list != null)
+ list.removeOnListChangedCallback(callback);
+ list = newList;
+ if (list != null) {
+ list.addOnListChangedCallback(callback);
+ callback.onChanged(list);
+ } else {
+ container.removeAllViews();
+ }
+ }
+
+ private static final class OnListChangedCallback<T>
+ extends ObservableList.OnListChangedCallback<ObservableList<T>> {
+
+ private final WeakReference<ItemChangeListener<T>> weakListener;
+
+ private OnListChangedCallback(final ItemChangeListener<T> listener) {
+ weakListener = new WeakReference<>(listener);
+ }
+
+ @Override
+ public void onChanged(final ObservableList<T> sender) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ // TODO: recycle views
+ listener.container.removeAllViews();
+ for (int i = 0; i < sender.size(); ++i)
+ listener.container.addView(listener.getView(i, null));
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeChanged(final ObservableList<T> sender, final int positionStart,
+ final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ for (int i = positionStart; i < positionStart + itemCount; ++i) {
+ final View child = listener.container.getChildAt(i);
+ listener.container.removeViewAt(i);
+ listener.container.addView(listener.getView(i, child));
+ }
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeInserted(final ObservableList<T> sender, final int positionStart,
+ final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ for (int i = positionStart; i < positionStart + itemCount; ++i)
+ listener.container.addView(listener.getView(i, null));
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeMoved(final ObservableList<T> sender, final int fromPosition,
+ final int toPosition, final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ final View[] views = new View[itemCount];
+ for (int i = 0; i < itemCount; ++i)
+ views[i] = listener.container.getChildAt(fromPosition + i);
+ listener.container.removeViews(fromPosition, itemCount);
+ for (int i = 0; i < itemCount; ++i)
+ listener.container.addView(views[i], toPosition + i);
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+
+ @Override
+ public void onItemRangeRemoved(final ObservableList<T> sender, final int positionStart,
+ final int itemCount) {
+ final ItemChangeListener<T> listener = weakListener.get();
+ if (listener != null) {
+ listener.container.removeViews(positionStart, itemCount);
+ } else {
+ sender.removeOnListChangedCallback(this);
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java
new file mode 100644
index 00000000..8b40dd91
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/databinding/ObservableKeyedRecyclerViewAdapter.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.databinding;
+
+import android.content.Context;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ObservableList;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerView.Adapter;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.util.ObservableKeyedList;
+import com.wireguard.util.Keyed;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * A generic {@code RecyclerView.Adapter} backed by a {@code ObservableKeyedList}.
+ */
+
+public class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extends Adapter<ObservableKeyedRecyclerViewAdapter.ViewHolder> {
+
+ private final OnListChangedCallback<E> callback = new OnListChangedCallback<>(this);
+ private final int layoutId;
+ private final LayoutInflater layoutInflater;
+ @Nullable private ObservableKeyedList<K, E> list;
+ @Nullable private RowConfigurationHandler rowConfigurationHandler;
+
+ ObservableKeyedRecyclerViewAdapter(final Context context, final int layoutId,
+ final ObservableKeyedList<K, E> list) {
+ this.layoutId = layoutId;
+ layoutInflater = LayoutInflater.from(context);
+ setList(list);
+ }
+
+ @Nullable
+ private E getItem(final int position) {
+ if (list == null || position < 0 || position >= list.size())
+ return null;
+ return list.get(position);
+ }
+
+ @Override
+ public int getItemCount() {
+ return list != null ? list.size() : 0;
+ }
+
+ @Override
+ public long getItemId(final int position) {
+ final K key = getKey(position);
+ return key != null ? key.hashCode() : -1;
+ }
+
+ @Nullable
+ private K getKey(final int position) {
+ final E item = getItem(position);
+ return item != null ? item.getKey() : null;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public void onBindViewHolder(final ViewHolder holder, final int position) {
+ holder.binding.setVariable(BR.collection, list);
+ holder.binding.setVariable(BR.key, getKey(position));
+ holder.binding.setVariable(BR.item, getItem(position));
+ holder.binding.executePendingBindings();
+
+ if (rowConfigurationHandler != null) {
+ final E item = getItem(position);
+ if (item != null) {
+ rowConfigurationHandler.onConfigureRow(holder.binding, item, position);
+ }
+ }
+ }
+
+ @Override
+ public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
+ return new ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false));
+ }
+
+ void setList(@Nullable final ObservableKeyedList<K, E> newList) {
+ if (list != null)
+ list.removeOnListChangedCallback(callback);
+ list = newList;
+ if (list != null) {
+ list.addOnListChangedCallback(callback);
+ }
+ notifyDataSetChanged();
+ }
+
+ void setRowConfigurationHandler(final RowConfigurationHandler rowConfigurationHandler) {
+ this.rowConfigurationHandler = rowConfigurationHandler;
+ }
+
+ public interface RowConfigurationHandler<B extends ViewDataBinding, T> {
+ void onConfigureRow(B binding, T item, int position);
+ }
+
+ private static final class OnListChangedCallback<E extends Keyed<?>>
+ extends ObservableList.OnListChangedCallback<ObservableList<E>> {
+
+ private final WeakReference<ObservableKeyedRecyclerViewAdapter<?, E>> weakAdapter;
+
+ private OnListChangedCallback(final ObservableKeyedRecyclerViewAdapter<?, E> adapter) {
+ weakAdapter = new WeakReference<>(adapter);
+ }
+
+ @Override
+ public void onChanged(final ObservableList<E> sender) {
+ final ObservableKeyedRecyclerViewAdapter adapter = weakAdapter.get();
+ if (adapter != null)
+ adapter.notifyDataSetChanged();
+ else
+ sender.removeOnListChangedCallback(this);
+ }
+
+ @Override
+ public void onItemRangeChanged(final ObservableList<E> sender, final int positionStart,
+ final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeInserted(final ObservableList<E> sender, final int positionStart,
+ final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeMoved(final ObservableList<E> sender, final int fromPosition,
+ final int toPosition, final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeRemoved(final ObservableList<E> sender, final int positionStart,
+ final int itemCount) {
+ onChanged(sender);
+ }
+ }
+
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ final ViewDataBinding binding;
+
+ public ViewHolder(final ViewDataBinding binding) {
+ super(binding.getRoot());
+
+ this.binding = binding;
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
new file mode 100644
index 00000000..3df141be
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Intent
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.fragment.app.Fragment
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.zxing.integration.android.IntentIntegrator
+import com.wireguard.android.R
+import com.wireguard.android.activity.TunnelCreatorActivity
+import com.wireguard.android.util.resolveAttribute
+
+class AddTunnelsSheet : BottomSheetDialogFragment() {
+
+ private lateinit var behavior: BottomSheetBehavior<FrameLayout>
+ private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
+ }
+ }
+ }
+
+ override fun getTheme(): Int {
+ return R.style.BottomSheetDialogTheme
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ return inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.peekHeight = 0
+ behavior.addBottomSheetCallback(bottomSheetCallback)
+ dialog.findViewById<View>(R.id.create_empty)?.setOnClickListener {
+ dismiss()
+ onRequestCreateConfig()
+ }
+ dialog.findViewById<View>(R.id.create_from_file)?.setOnClickListener {
+ dismiss()
+ onRequestImportConfig()
+ }
+ dialog.findViewById<View>(R.id.create_from_qrcode)?.setOnClickListener {
+ dismiss()
+ onRequestScanQRCode()
+ }
+ }
+ })
+ val gradientDrawable = GradientDrawable().apply {
+ setColor(requireContext().resolveAttribute(R.attr.colorBackground))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior.removeBottomSheetCallback(bottomSheetCallback)
+ }
+
+ private fun requireTargetFragment(): Fragment {
+ return requireNotNull(targetFragment) { "A target fragment should always be set" }
+ }
+
+ private fun onRequestCreateConfig() {
+ startActivity(Intent(activity, TunnelCreatorActivity::class.java))
+ }
+
+ private fun onRequestImportConfig() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ }
+ requireTargetFragment().startActivityForResult(intent, TunnelListFragment.REQUEST_IMPORT)
+ }
+
+ private fun onRequestScanQRCode() {
+ val integrator = IntentIntegrator.forSupportFragment(requireTargetFragment()).apply {
+ setOrientationLocked(false)
+ setBeepEnabled(false)
+ setPrompt(getString(R.string.qr_code_hint))
+ }
+ integrator.initiateScan(listOf(IntentIntegrator.QR_CODE))
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java
new file mode 100644
index 00000000..43178665
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.appcompat.app.AlertDialog;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.AppListDialogFragmentBinding;
+import com.wireguard.android.model.ApplicationData;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.ObservableKeyedArrayList;
+import com.wireguard.android.util.ObservableKeyedList;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import java9.util.Comparators;
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+public class AppListDialogFragment extends DialogFragment {
+
+ private static final String KEY_EXCLUDED_APPS = "excludedApps";
+ private final ObservableKeyedList<String, ApplicationData> appData = new ObservableKeyedArrayList<>();
+ private List<String> currentlyExcludedApps = Collections.emptyList();
+
+ public static <T extends Fragment & AppExclusionListener>
+ AppListDialogFragment newInstance(final ArrayList<String> excludedApps, final T target) {
+ final Bundle extras = new Bundle();
+ extras.putStringArrayList(KEY_EXCLUDED_APPS, excludedApps);
+ final AppListDialogFragment fragment = new AppListDialogFragment();
+ fragment.setTargetFragment(target, 0);
+ fragment.setArguments(extras);
+ return fragment;
+ }
+
+ private void loadData() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return;
+ }
+
+ final PackageManager pm = activity.getPackageManager();
+ Application.getAsyncWorker().supplyAsync(() -> {
+ final Intent launcherIntent = new Intent(Intent.ACTION_MAIN, null);
+ launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ final List<ResolveInfo> resolveInfos = pm.queryIntentActivities(launcherIntent, 0);
+
+ final List<ApplicationData> applicationData = new ArrayList<>();
+ for (ResolveInfo resolveInfo : resolveInfos) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ applicationData.add(new ApplicationData(resolveInfo.loadIcon(pm), resolveInfo.loadLabel(pm).toString(), packageName, currentlyExcludedApps.contains(packageName)));
+ }
+
+ Collections.sort(applicationData, Comparators.comparing(ApplicationData::getName, String.CASE_INSENSITIVE_ORDER));
+ return applicationData;
+ }).whenComplete(((data, throwable) -> {
+ if (data != null) {
+ appData.clear();
+ appData.addAll(data);
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ final String message = activity.getString(R.string.error_fetching_apps, error);
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show();
+ dismissAllowingStateLoss();
+ }
+ }));
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final List<String> excludedApps = requireArguments().getStringArrayList(KEY_EXCLUDED_APPS);
+ currentlyExcludedApps = (excludedApps != null) ? excludedApps : Collections.emptyList();
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
+ final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(requireActivity());
+ alertDialogBuilder.setTitle(R.string.excluded_applications);
+
+ final AppListDialogFragmentBinding binding = AppListDialogFragmentBinding.inflate(requireActivity().getLayoutInflater(), null, false);
+ binding.executePendingBindings();
+ alertDialogBuilder.setView(binding.getRoot());
+
+ alertDialogBuilder.setPositiveButton(R.string.set_exclusions, (dialog, which) -> setExclusionsAndDismiss());
+ alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss());
+ alertDialogBuilder.setNeutralButton(R.string.toggle_all, (dialog, which) -> {
+ });
+
+ binding.setFragment(this);
+ binding.setAppData(appData);
+
+ loadData();
+
+ final AlertDialog dialog = alertDialogBuilder.create();
+ dialog.setOnShowListener(d -> dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener(view -> {
+ final List<ApplicationData> selectedItems = StreamSupport.stream(appData)
+ .filter(ApplicationData::isExcludedFromTunnel)
+ .collect(Collectors.toList());
+ final boolean excludeAll = selectedItems.isEmpty();
+ for (final ApplicationData app : appData)
+ app.setExcludedFromTunnel(excludeAll);
+ }));
+ return dialog;
+ }
+
+ private void setExclusionsAndDismiss() {
+ final List<String> excludedApps = new ArrayList<>();
+ for (final ApplicationData data : appData) {
+ if (data.isExcludedFromTunnel()) {
+ excludedApps.add(data.getPackageName());
+ }
+ }
+
+ ((AppExclusionListener) getTargetFragment()).onExcludedAppsSelected(excludedApps);
+ dismiss();
+ }
+
+ public interface AppExclusionListener {
+ void onExcludedAppsSelected(List<String> excludedApps);
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java
new file mode 100644
index 00000000..23bf44e7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.fragment.app.Fragment;
+import android.util.Log;
+import android.view.View;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.activity.BaseActivity;
+import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener;
+import com.wireguard.android.backend.GoBackend;
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding;
+import com.wireguard.android.databinding.TunnelListItemBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.util.ErrorMessages;
+
+/**
+ * Base class for fragments that need to know the currently-selected tunnel. Only does anything when
+ * attached to a {@code BaseActivity}.
+ */
+
+public abstract class BaseFragment extends Fragment implements OnSelectedTunnelChangedListener {
+ private static final int REQUEST_CODE_VPN_PERMISSION = 23491;
+ private static final String TAG = "WireGuard/" + BaseFragment.class.getSimpleName();
+ @Nullable private BaseActivity activity;
+ @Nullable private ObservableTunnel pendingTunnel;
+ @Nullable private Boolean pendingTunnelUp;
+
+ @Nullable
+ protected ObservableTunnel getSelectedTunnel() {
+ return activity != null ? activity.getSelectedTunnel() : null;
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
+ if (pendingTunnel != null && pendingTunnelUp != null)
+ setTunnelStateWithPermissionsResult(pendingTunnel, pendingTunnelUp);
+ pendingTunnel = null;
+ pendingTunnelUp = null;
+ }
+ }
+
+ @Override
+ public void onAttach(final Context context) {
+ super.onAttach(context);
+ if (context instanceof BaseActivity) {
+ activity = (BaseActivity) context;
+ activity.addOnSelectedTunnelChangedListener(this);
+ } else {
+ activity = null;
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ if (activity != null)
+ activity.removeOnSelectedTunnelChangedListener(this);
+ activity = null;
+ super.onDetach();
+ }
+
+ protected void setSelectedTunnel(@Nullable final ObservableTunnel tunnel) {
+ if (activity != null)
+ activity.setSelectedTunnel(tunnel);
+ }
+
+ public void setTunnelState(final View view, final boolean checked) {
+ final ViewDataBinding binding = DataBindingUtil.findBinding(view);
+ final ObservableTunnel tunnel;
+ if (binding instanceof TunnelDetailFragmentBinding)
+ tunnel = ((TunnelDetailFragmentBinding) binding).getTunnel();
+ else if (binding instanceof TunnelListItemBinding)
+ tunnel = ((TunnelListItemBinding) binding).getItem();
+ else
+ return;
+ if (tunnel == null)
+ return;
+
+ Application.getBackendAsync().thenAccept(backend -> {
+ if (backend instanceof GoBackend) {
+ final Intent intent = GoBackend.VpnService.prepare(view.getContext());
+ if (intent != null) {
+ pendingTunnel = tunnel;
+ pendingTunnelUp = checked;
+ startActivityForResult(intent, REQUEST_CODE_VPN_PERMISSION);
+ return;
+ }
+ }
+
+ setTunnelStateWithPermissionsResult(tunnel, checked);
+ });
+ }
+
+ private void setTunnelStateWithPermissionsResult(final ObservableTunnel tunnel, final boolean checked) {
+ tunnel.setState(State.of(checked)).whenComplete((state, throwable) -> {
+ if (throwable == null)
+ return;
+ final String error = ErrorMessages.get(throwable);
+ final int messageResId = checked ? R.string.error_up : R.string.error_down;
+ final String message = requireContext().getString(messageResId, error);
+ final View view = getView();
+ if (view != null)
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG).show();
+ else
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
+ Log.e(TAG, message, throwable);
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java
new file mode 100644
index 00000000..effa0593
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.appcompat.app.AlertDialog;
+import android.view.inputmethod.InputMethodManager;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+public class ConfigNamingDialogFragment extends DialogFragment {
+ private static final String KEY_CONFIG_TEXT = "config_text";
+
+ @Nullable private ConfigNamingDialogFragmentBinding binding;
+ @Nullable private Config config;
+ @Nullable private InputMethodManager imm;
+
+ public static ConfigNamingDialogFragment newInstance(final String configText) {
+ final Bundle extras = new Bundle();
+ extras.putString(KEY_CONFIG_TEXT, configText);
+ final ConfigNamingDialogFragment fragment = new ConfigNamingDialogFragment();
+ fragment.setArguments(extras);
+ return fragment;
+ }
+
+ private void createTunnelAndDismiss() {
+ if (binding != null) {
+ final String name = binding.tunnelNameText.getText().toString();
+
+ Application.getTunnelManager().create(name, config).whenComplete((tunnel, throwable) -> {
+ if (tunnel != null) {
+ dismiss();
+ } else {
+ binding.tunnelNameTextLayout.setError(throwable.getMessage());
+ }
+ });
+ }
+ }
+
+ @Override
+ public void dismiss() {
+ setKeyboardVisible(false);
+ super.dismiss();
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle arguments = getArguments();
+ final String configText = arguments.getString(KEY_CONFIG_TEXT);
+ final byte[] configBytes = configText.getBytes(StandardCharsets.UTF_8);
+ try {
+ config = Config.parse(new ByteArrayInputStream(configBytes));
+ } catch (final BadConfigException | IOException e) {
+ throw new IllegalArgumentException("Invalid config passed to " + getClass().getSimpleName(), e);
+ }
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Activity activity = requireActivity();
+
+ imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+
+ final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(activity);
+ alertDialogBuilder.setTitle(R.string.import_from_qr_code);
+
+ binding = ConfigNamingDialogFragmentBinding.inflate(activity.getLayoutInflater(), null, false);
+ binding.executePendingBindings();
+ alertDialogBuilder.setView(binding.getRoot());
+
+ alertDialogBuilder.setPositiveButton(R.string.create_tunnel, null);
+ alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dismiss());
+
+ return alertDialogBuilder.create();
+ }
+
+ @Override public void onResume() {
+ super.onResume();
+
+ final AlertDialog dialog = (AlertDialog) getDialog();
+ if (dialog != null) {
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> createTunnelAndDismiss());
+
+ setKeyboardVisible(true);
+ }
+ }
+
+ private void setKeyboardVisible(final boolean visible) {
+ Objects.requireNonNull(imm);
+
+ if (visible) {
+ imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
+ } else if (binding != null) {
+ imm.hideSoftInputFromWindow(binding.tunnelNameText.getWindowToken(), 0);
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
new file mode 100644
index 00000000..8d90fa7e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.databinding.DataBindingUtil;
+
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding;
+import com.wireguard.android.databinding.TunnelDetailPeerBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.crypto.Key;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * Fragment that shows details about a specific tunnel.
+ */
+
+public class TunnelDetailFragment extends BaseFragment {
+ @Nullable private TunnelDetailFragmentBinding binding;
+ @Nullable private Timer timer;
+ @Nullable private State lastState = State.TOGGLE;
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ inflater.inflate(R.menu.tunnel_detail, menu);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (timer != null) {
+ timer.cancel();
+ timer = null;
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ timer = new Timer();
+ timer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ updateStats();
+ }
+ }, 0, 1000);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelDetailFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpScrollingContent((ViewGroup) binding.getRoot(), null);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ if (binding == null)
+ return;
+ binding.setTunnel(newTunnel);
+ if (newTunnel == null)
+ binding.setConfig(null);
+ else
+ newTunnel.getConfigAsync().thenAccept(binding::setConfig);
+ lastState = State.TOGGLE;
+ updateStats();
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+ onSelectedTunnelChanged(null, getSelectedTunnel());
+ super.onViewStateRestored(savedInstanceState);
+ }
+
+ private String formatBytes(final long bytes) {
+ if (bytes < 1024)
+ return requireContext().getString(R.string.transfer_bytes, bytes);
+ else if (bytes < 1024*1024)
+ return requireContext().getString(R.string.transfer_kibibytes, bytes/1024.0);
+ else if (bytes < 1024*1024*1024)
+ return requireContext().getString(R.string.transfer_mibibytes, bytes/(1024.0*1024.0));
+ else if (bytes < 1024*1024*1024*1024)
+ return requireContext().getString(R.string.transfer_gibibytes, bytes/(1024.0*1024.0*1024.0));
+ return requireContext().getString(R.string.transfer_tibibytes, bytes/(1024.0*1024.0*1024.0)/1024.0);
+ }
+
+ private void updateStats() {
+ if (binding == null || !isResumed())
+ return;
+ final ObservableTunnel tunnel = binding.getTunnel();
+ if (tunnel == null)
+ return;
+ final State state = tunnel.getState();
+ if (state != State.UP && lastState == state)
+ return;
+ lastState = state;
+ tunnel.getStatisticsAsync().whenComplete((statistics, throwable) -> {
+ if (throwable != null) {
+ for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) {
+ final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i));
+ if (peer == null)
+ continue;
+ peer.transferLabel.setVisibility(View.GONE);
+ peer.transferText.setVisibility(View.GONE);
+ }
+ return;
+ }
+ for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) {
+ final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i));
+ if (peer == null)
+ continue;
+ final Key publicKey = peer.getItem().getPublicKey();
+ final long rx = statistics.peerRx(publicKey);
+ final long tx = statistics.peerTx(publicKey);
+ if (rx == 0 && tx == 0) {
+ peer.transferLabel.setVisibility(View.GONE);
+ peer.transferText.setVisibility(View.GONE);
+ continue;
+ }
+ peer.transferText.setText(requireContext().getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx)));
+ peer.transferLabel.setVisibility(View.VISIBLE);
+ peer.transferText.setVisibility(View.VISIBLE);
+ }
+ });
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java
new file mode 100644
index 00000000..92aeb52a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import androidx.databinding.ObservableList;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.TunnelEditorFragmentBinding;
+import com.wireguard.android.fragment.AppListDialogFragment.AppExclusionListener;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.viewmodel.ConfigProxy;
+import com.wireguard.config.Config;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Fragment for editing a WireGuard configuration.
+ */
+
+public class TunnelEditorFragment extends BaseFragment implements AppExclusionListener {
+ private static final String KEY_LOCAL_CONFIG = "local_config";
+ private static final String KEY_ORIGINAL_NAME = "original_name";
+ private static final String TAG = "WireGuard/" + TunnelEditorFragment.class.getSimpleName();
+
+ @Nullable private TunnelEditorFragmentBinding binding;
+ @Nullable private ObservableTunnel tunnel;
+
+ private void onConfigLoaded(final Config config) {
+ if (binding != null) {
+ binding.setConfig(new ConfigProxy(config));
+ }
+ }
+
+ private void onConfigSaved(final ObservableTunnel savedTunnel,
+ @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getString(R.string.config_save_success, savedTunnel.getName());
+ Log.d(TAG, message);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ onFinished();
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.config_save_error, savedTunnel.getName(), error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ inflater.inflate(R.menu.config_editor, menu);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelEditorFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpScrollingContent(binding.mainContainer, null);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onExcludedAppsSelected(final List<String> excludedApps) {
+ Objects.requireNonNull(binding, "Tried to set excluded apps while no view was loaded");
+ final ObservableList<String> excludedApplications =
+ binding.getConfig().getInterface().getExcludedApplications();
+ excludedApplications.clear();
+ excludedApplications.addAll(excludedApps);
+ }
+
+ private void onFinished() {
+ // Hide the keyboard; it rarely goes away on its own.
+ final Activity activity = getActivity();
+ if (activity == null) return;
+ final View focusedView = activity.getCurrentFocus();
+ if (focusedView != null) {
+ final Object service = activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+ final InputMethodManager inputManager = (InputMethodManager) service;
+ if (inputManager != null)
+ inputManager.hideSoftInputFromWindow(focusedView.getWindowToken(),
+ InputMethodManager.HIDE_NOT_ALWAYS);
+ }
+ // Tell the activity to finish itself or go back to the detail view.
+ getActivity().runOnUiThread(() -> {
+ // TODO(smaeul): Remove this hack when fixing the Config ViewModel
+ // The selected tunnel has to actually change, but we have to remember this one.
+ final ObservableTunnel savedTunnel = tunnel;
+ if (savedTunnel == getSelectedTunnel())
+ setSelectedTunnel(null);
+ setSelectedTunnel(savedTunnel);
+ });
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_save:
+ if (binding == null)
+ return false;
+ final Config newConfig;
+ try {
+ newConfig = binding.getConfig().resolve();
+ } catch (final Exception e) {
+ final String error = ErrorMessages.get(e);
+ final String tunnelName = tunnel == null ? binding.getName() : tunnel.getName();
+ final String message = getString(R.string.config_save_error, tunnelName, error);
+ Log.e(TAG, message, e);
+ Snackbar.make(binding.mainContainer, error, Snackbar.LENGTH_LONG).show();
+ return false;
+ }
+ if (tunnel == null) {
+ Log.d(TAG, "Attempting to create new tunnel " + binding.getName());
+ final TunnelManager manager = Application.getTunnelManager();
+ manager.create(binding.getName(), newConfig)
+ .whenComplete(this::onTunnelCreated);
+ } else if (!tunnel.getName().equals(binding.getName())) {
+ Log.d(TAG, "Attempting to rename tunnel to " + binding.getName());
+ tunnel.setName(binding.getName())
+ .whenComplete((a, b) -> onTunnelRenamed(tunnel, newConfig, b));
+ } else {
+ Log.d(TAG, "Attempting to save config of " + tunnel.getName());
+ tunnel.setConfig(newConfig)
+ .whenComplete((a, b) -> onConfigSaved(tunnel, b));
+ }
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ public void onRequestSetExcludedApplications(@SuppressWarnings("unused") final View view) {
+ if (binding != null) {
+ final ArrayList<String> excludedApps = new ArrayList<>(binding.getConfig().getInterface().getExcludedApplications());
+ final AppListDialogFragment fragment = AppListDialogFragment.newInstance(excludedApps, this);
+ fragment.show(getParentFragmentManager(), null);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ if (binding != null)
+ outState.putParcelable(KEY_LOCAL_CONFIG, binding.getConfig());
+ outState.putString(KEY_ORIGINAL_NAME, tunnel == null ? null : tunnel.getName());
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel,
+ @Nullable final ObservableTunnel newTunnel) {
+ tunnel = newTunnel;
+ if (binding == null)
+ return;
+ binding.setConfig(new ConfigProxy());
+ if (tunnel != null) {
+ binding.setName(tunnel.getName());
+ tunnel.getConfigAsync().thenAccept(this::onConfigLoaded);
+ } else {
+ binding.setName("");
+ }
+ }
+
+ private void onTunnelCreated(final ObservableTunnel newTunnel, @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ tunnel = newTunnel;
+ message = getString(R.string.tunnel_create_success, tunnel.getName());
+ Log.d(TAG, message);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ onFinished();
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.tunnel_create_error, error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ private void onTunnelRenamed(final ObservableTunnel renamedTunnel, final Config newConfig,
+ @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getString(R.string.tunnel_rename_success, renamedTunnel.getName());
+ Log.d(TAG, message);
+ // Now save the rest of configuration changes.
+ Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel.getName());
+ renamedTunnel.setConfig(newConfig).whenComplete((a, b) -> onConfigSaved(renamedTunnel, b));
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.tunnel_rename_error, error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+
+ if (savedInstanceState == null) {
+ onSelectedTunnelChanged(null, getSelectedTunnel());
+ } else {
+ tunnel = getSelectedTunnel();
+ final ConfigProxy config = savedInstanceState.getParcelable(KEY_LOCAL_CONFIG);
+ final String originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME);
+ if (tunnel != null && !tunnel.getName().equals(originalName))
+ onSelectedTunnelChanged(null, tunnel);
+ else
+ binding.setConfig(config);
+ }
+
+ super.onViewStateRestored(savedInstanceState);
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java
new file mode 100644
index 00000000..21618e60
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java
@@ -0,0 +1,449 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.OpenableColumns;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.view.ActionMode;
+import androidx.recyclerview.widget.RecyclerView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.google.zxing.integration.android.IntentIntegrator;
+import com.google.zxing.integration.android.IntentResult;
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.activity.TunnelCreatorActivity;
+import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter;
+import com.wireguard.android.databinding.TunnelListFragmentBinding;
+import com.wireguard.android.databinding.TunnelListItemBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.widget.MultiselectableRelativeLayout;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.stream.StreamSupport;
+
+/**
+ * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels.
+ */
+
+public class TunnelListFragment extends BaseFragment {
+ public static final int REQUEST_IMPORT = 1;
+ private static final int REQUEST_TARGET_FRAGMENT = 2;
+ private static final String TAG = "WireGuard/" + TunnelListFragment.class.getSimpleName();
+
+ private final ActionModeListener actionModeListener = new ActionModeListener();
+ @Nullable private ActionMode actionMode;
+ @Nullable private TunnelListFragmentBinding binding;
+
+ private void importTunnel(@NonNull final String configText) {
+ try {
+ // Ensure the config text is parseable before proceeding…
+ Config.parse(new ByteArrayInputStream(configText.getBytes(StandardCharsets.UTF_8)));
+
+ // Config text is valid, now create the tunnel…
+ ConfigNamingDialogFragment.newInstance(configText).show(getParentFragmentManager(), null);
+ } catch (final BadConfigException | IOException e) {
+ onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(e));
+ }
+ }
+
+ private void importTunnel(@Nullable final Uri uri) {
+ final Activity activity = getActivity();
+ if (activity == null || uri == null)
+ return;
+ final ContentResolver contentResolver = activity.getContentResolver();
+
+ final Collection<CompletableFuture<ObservableTunnel>> futureTunnels = new ArrayList<>();
+ final List<Throwable> throwables = new ArrayList<>();
+ Application.getAsyncWorker().supplyAsync(() -> {
+ final String[] columns = {OpenableColumns.DISPLAY_NAME};
+ String name = null;
+ try (Cursor cursor = contentResolver.query(uri, columns,
+ null, null, null)) {
+ if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0))
+ name = cursor.getString(0);
+ }
+ if (name == null)
+ name = Uri.decode(uri.getLastPathSegment());
+ int idx = name.lastIndexOf('/');
+ if (idx >= 0) {
+ if (idx >= name.length() - 1)
+ throw new IllegalArgumentException(getResources().getString(R.string.illegal_filename_error, name));
+ name = name.substring(idx + 1);
+ }
+ boolean isZip = name.toLowerCase(Locale.ENGLISH).endsWith(".zip");
+ if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf"))
+ name = name.substring(0, name.length() - ".conf".length());
+ else if (!isZip)
+ throw new IllegalArgumentException(getResources().getString(R.string.bad_extension_error));
+
+ if (isZip) {
+ try (ZipInputStream zip = new ZipInputStream(contentResolver.openInputStream(uri));
+ BufferedReader reader = new BufferedReader(new InputStreamReader(zip))) {
+ ZipEntry entry;
+ while ((entry = zip.getNextEntry()) != null) {
+ if (entry.isDirectory())
+ continue;
+ name = entry.getName();
+ idx = name.lastIndexOf('/');
+ if (idx >= 0) {
+ if (idx >= name.length() - 1)
+ continue;
+ name = name.substring(name.lastIndexOf('/') + 1);
+ }
+ if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf"))
+ name = name.substring(0, name.length() - ".conf".length());
+ else
+ continue;
+ Config config = null;
+ try {
+ config = Config.parse(reader);
+ } catch (Exception e) {
+ throwables.add(e);
+ }
+ if (config != null)
+ futureTunnels.add(Application.getTunnelManager().create(name, config).toCompletableFuture());
+ }
+ }
+ } else {
+ futureTunnels.add(Application.getTunnelManager().create(name,
+ Config.parse(contentResolver.openInputStream(uri))).toCompletableFuture());
+ }
+
+ if (futureTunnels.isEmpty()) {
+ if (throwables.size() == 1)
+ throw throwables.get(0);
+ else if (throwables.isEmpty())
+ throw new IllegalArgumentException(getResources().getString(R.string.no_configs_error));
+ }
+
+ return CompletableFuture.allOf(futureTunnels.toArray(new CompletableFuture[futureTunnels.size()]));
+ }).whenComplete((future, exception) -> {
+ if (exception != null) {
+ onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(exception));
+ } else {
+ future.whenComplete((ignored1, ignored2) -> {
+ final List<ObservableTunnel> tunnels = new ArrayList<>(futureTunnels.size());
+ for (final CompletableFuture<ObservableTunnel> futureTunnel : futureTunnels) {
+ ObservableTunnel tunnel = null;
+ try {
+ tunnel = futureTunnel.getNow(null);
+ } catch (final Exception e) {
+ throwables.add(e);
+ }
+ if (tunnel != null)
+ tunnels.add(tunnel);
+ }
+ onTunnelImportFinished(tunnels, throwables);
+ });
+ }
+ });
+ }
+
+ @Override
+ public void onActivityCreated(@Nullable final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ final Collection<Integer> checkedItems = savedInstanceState.getIntegerArrayList("CHECKED_ITEMS");
+ if (checkedItems != null) {
+ for (final Integer i : checkedItems)
+ actionModeListener.setItemChecked(i, true);
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) {
+ switch (requestCode) {
+ case REQUEST_IMPORT:
+ if (resultCode == Activity.RESULT_OK && data != null)
+ importTunnel(data.getData());
+ return;
+ case IntentIntegrator.REQUEST_CODE:
+ final IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
+ if (result != null && result.getContents() != null) {
+ importTunnel(result.getContents());
+ }
+ return;
+ default:
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelListFragmentBinding.inflate(inflater, container, false);
+ binding.createFab.setOnClickListener(v -> {
+ final AddTunnelsSheet bottomSheet = new AddTunnelsSheet();
+ bottomSheet.setTargetFragment(this, REQUEST_TARGET_FRAGMENT);
+ bottomSheet.show(getParentFragmentManager(), "BOTTOM_SHEET");
+ });
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpFAB(binding.createFab);
+ EdgeToEdge.setUpScrollingContent(binding.tunnelList, binding.createFab);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ }
+
+ public void onRequestCreateConfig(@SuppressWarnings("unused") final View view) {
+ startActivity(new Intent(getActivity(), TunnelCreatorActivity.class));
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putIntegerArrayList("CHECKED_ITEMS", actionModeListener.getCheckedItems());
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ if (binding == null)
+ return;
+ Application.getTunnelManager().getTunnels().thenAccept(tunnels -> {
+ if (newTunnel != null)
+ viewForTunnel(newTunnel, tunnels).setSingleSelected(true);
+ if (oldTunnel != null)
+ viewForTunnel(oldTunnel, tunnels).setSingleSelected(false);
+ });
+ }
+
+ private void showSnackbar(final CharSequence message) {
+ if (binding != null) {
+ final Snackbar snackbar = Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG);
+ snackbar.setAnchorView(binding.createFab);
+ snackbar.show();
+ }
+ }
+
+ private void onTunnelDeletionFinished(final Integer count, @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getResources().getQuantityString(R.plurals.delete_success, count, count);
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getResources().getQuantityString(R.plurals.delete_error, count, count, error);
+ Log.e(TAG, message, throwable);
+ }
+ showSnackbar(message);
+ }
+
+ private void onTunnelImportFinished(final List<ObservableTunnel> tunnels, final Collection<Throwable> throwables) {
+ String message = null;
+
+ for (final Throwable throwable : throwables) {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.import_error, error);
+ Log.e(TAG, message, throwable);
+ }
+
+ if (tunnels.size() == 1 && throwables.isEmpty())
+ message = getString(R.string.import_success, tunnels.get(0).getName());
+ else if (tunnels.isEmpty() && throwables.size() == 1)
+ /* Use the exception message from above. */ ;
+ else if (throwables.isEmpty())
+ message = getResources().getQuantityString(R.plurals.import_total_success,
+ tunnels.size(), tunnels.size());
+ else if (!throwables.isEmpty())
+ message = getResources().getQuantityString(R.plurals.import_partial_success,
+ tunnels.size() + throwables.size(),
+ tunnels.size(), tunnels.size() + throwables.size());
+
+ showSnackbar(message);
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ super.onViewStateRestored(savedInstanceState);
+
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+ Application.getTunnelManager().getTunnels().thenAccept(binding::setTunnels);
+ binding.setRowConfigurationHandler((ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TunnelListItemBinding, ObservableTunnel>) (binding, tunnel, position) -> {
+ binding.setFragment(this);
+ binding.getRoot().setOnClickListener(clicked -> {
+ if (actionMode == null) {
+ setSelectedTunnel(tunnel);
+ } else {
+ actionModeListener.toggleItemChecked(position);
+ }
+ });
+ binding.getRoot().setOnLongClickListener(clicked -> {
+ actionModeListener.toggleItemChecked(position);
+ return true;
+ });
+
+ if (actionMode != null)
+ ((MultiselectableRelativeLayout) binding.getRoot()).setMultiSelected(actionModeListener.checkedItems.contains(position));
+ else
+ ((MultiselectableRelativeLayout) binding.getRoot()).setSingleSelected(getSelectedTunnel() == tunnel);
+ });
+ }
+
+ private MultiselectableRelativeLayout viewForTunnel(final ObservableTunnel tunnel, final List tunnels) {
+ return (MultiselectableRelativeLayout) binding.tunnelList.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel)).itemView;
+ }
+
+ private final class ActionModeListener implements ActionMode.Callback {
+ private final Collection<Integer> checkedItems = new HashSet<>();
+
+ @Nullable private Resources resources;
+
+ public ArrayList<Integer> getCheckedItems() {
+ return new ArrayList<>(checkedItems);
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_delete:
+ final Iterable<Integer> copyCheckedItems = new HashSet<>(checkedItems);
+ Application.getTunnelManager().getTunnels().thenAccept(tunnels -> {
+ final Collection<ObservableTunnel> tunnelsToDelete = new ArrayList<>();
+ for (final Integer position : copyCheckedItems)
+ tunnelsToDelete.add(tunnels.get(position));
+
+ final CompletableFuture[] futures = StreamSupport.stream(tunnelsToDelete)
+ .map(ObservableTunnel::delete)
+ .toArray(CompletableFuture[]::new);
+ CompletableFuture.allOf(futures)
+ .thenApply(x -> futures.length)
+ .whenComplete(TunnelListFragment.this::onTunnelDeletionFinished);
+
+ });
+ checkedItems.clear();
+ mode.finish();
+ return true;
+ case R.id.menu_action_select_all:
+ Application.getTunnelManager().getTunnels().thenAccept(tunnels -> {
+ for (int i = 0; i < tunnels.size(); ++i) {
+ setItemChecked(i, true);
+ }
+ });
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode mode, final Menu menu) {
+ actionMode = mode;
+ if (getActivity() != null) {
+ resources = getActivity().getResources();
+ }
+ mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu);
+ binding.tunnelList.getAdapter().notifyDataSetChanged();
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode mode) {
+ actionMode = null;
+ resources = null;
+ checkedItems.clear();
+ binding.tunnelList.getAdapter().notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) {
+ updateTitle(mode);
+ return false;
+ }
+
+ void setItemChecked(final int position, final boolean checked) {
+ if (checked) {
+ checkedItems.add(position);
+ } else {
+ checkedItems.remove(position);
+ }
+
+ final RecyclerView.Adapter adapter = binding == null ? null : binding.tunnelList.getAdapter();
+
+ if (actionMode == null && !checkedItems.isEmpty() && getActivity() != null) {
+ ((AppCompatActivity) getActivity()).startSupportActionMode(this);
+ } else if (actionMode != null && checkedItems.isEmpty()) {
+ actionMode.finish();
+ }
+
+ if (adapter != null)
+ adapter.notifyItemChanged(position);
+
+ updateTitle(actionMode);
+ }
+
+ void toggleItemChecked(final int position) {
+ setItemChecked(position, !checkedItems.contains(position));
+ }
+
+ private void updateTitle(@Nullable final ActionMode mode) {
+ if (mode == null) {
+ return;
+ }
+
+ final int count = checkedItems.size();
+ if (count == 0) {
+ mode.setTitle("");
+ } else {
+ mode.setTitle(resources.getQuantityString(R.plurals.delete_title, count, count));
+ }
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ApplicationData.java b/ui/src/main/java/com/wireguard/android/model/ApplicationData.java
new file mode 100644
index 00000000..65edff90
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ApplicationData.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import android.graphics.drawable.Drawable;
+
+import com.wireguard.android.BR;
+import com.wireguard.util.Keyed;
+
+public class ApplicationData extends BaseObservable implements Keyed<String> {
+ private final Drawable icon;
+ private final String name;
+ private final String packageName;
+ private boolean excludedFromTunnel;
+
+ public ApplicationData(final Drawable icon, final String name, final String packageName, final boolean excludedFromTunnel) {
+ this.icon = icon;
+ this.name = name;
+ this.packageName = packageName;
+ this.excludedFromTunnel = excludedFromTunnel;
+ }
+
+ public Drawable getIcon() {
+ return icon;
+ }
+
+ @Override
+ public String getKey() {
+ return name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ @Bindable
+ public boolean isExcludedFromTunnel() {
+ return excludedFromTunnel;
+ }
+
+ public void setExcludedFromTunnel(final boolean excludedFromTunnel) {
+ this.excludedFromTunnel = excludedFromTunnel;
+ notifyPropertyChanged(BR.excludedFromTunnel);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java
new file mode 100644
index 00000000..ce3197f2
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/ObservableTunnel.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.backend.Statistics;
+import com.wireguard.android.backend.Tunnel;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.config.Config;
+import com.wireguard.util.Keyed;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel.
+ */
+
+public class ObservableTunnel extends BaseObservable implements Keyed<String>, Tunnel {
+ private final TunnelManager manager;
+ @Nullable private Config config;
+ private State state;
+ private String name;
+ @Nullable private Statistics statistics;
+
+ ObservableTunnel(final TunnelManager manager, final String name,
+ @Nullable final Config config, final State state) {
+ this.name = name;
+ this.manager = manager;
+ this.config = config;
+ this.state = state;
+ }
+
+ public CompletionStage<Void> delete() {
+ return manager.delete(this);
+ }
+
+ @Bindable
+ @Nullable
+ public Config getConfig() {
+ if (config == null)
+ manager.getTunnelConfig(this).whenComplete(ExceptionLoggers.E);
+ return config;
+ }
+
+ public CompletionStage<Config> getConfigAsync() {
+ if (config == null)
+ return manager.getTunnelConfig(this);
+ return CompletableFuture.completedFuture(config);
+ }
+
+ @Override
+ public String getKey() {
+ return name;
+ }
+
+ @Override
+ @Bindable
+ public String getName() {
+ return name;
+ }
+
+ @Bindable
+ public State getState() {
+ return state;
+ }
+
+ public CompletionStage<State> getStateAsync() {
+ return TunnelManager.getTunnelState(this);
+ }
+
+ @Bindable
+ @Nullable
+ public Statistics getStatistics() {
+ if (statistics == null || statistics.isStale())
+ TunnelManager.getTunnelStatistics(this).whenComplete(ExceptionLoggers.E);
+ return statistics;
+ }
+
+ public CompletionStage<Statistics> getStatisticsAsync() {
+ if (statistics == null || statistics.isStale())
+ return TunnelManager.getTunnelStatistics(this);
+ return CompletableFuture.completedFuture(statistics);
+ }
+
+ Config onConfigChanged(final Config config) {
+ this.config = config;
+ notifyPropertyChanged(BR.config);
+ return config;
+ }
+
+ String onNameChanged(final String name) {
+ this.name = name;
+ notifyPropertyChanged(BR.name);
+ return name;
+ }
+
+ State onStateChanged(final State state) {
+ if (state != State.UP)
+ onStatisticsChanged(null);
+ this.state = state;
+ notifyPropertyChanged(BR.state);
+ return state;
+ }
+
+ @Override
+ public void onStateChange(final State newState) {
+ onStateChanged(state);
+ }
+
+ @Nullable
+ Statistics onStatisticsChanged(@Nullable final Statistics statistics) {
+ this.statistics = statistics;
+ notifyPropertyChanged(BR.statistics);
+ return statistics;
+ }
+
+ public CompletionStage<Config> setConfig(final Config config) {
+ if (!config.equals(this.config))
+ return manager.setTunnelConfig(this, config);
+ return CompletableFuture.completedFuture(this.config);
+ }
+
+ public CompletionStage<String> setName(final String name) {
+ if (!name.equals(this.name))
+ return manager.setTunnelName(this, name);
+ return CompletableFuture.completedFuture(this.name);
+ }
+
+ public CompletionStage<State> setState(final State state) {
+ if (state != this.state)
+ return manager.setTunnelState(this, state);
+ return CompletableFuture.completedFuture(this.state);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/model/TunnelManager.java b/ui/src/main/java/com/wireguard/android/model/TunnelManager.java
new file mode 100644
index 00000000..35d56c81
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/model/TunnelManager.java
@@ -0,0 +1,301 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.model;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.BR;
+import com.wireguard.android.R;
+import com.wireguard.android.configStore.ConfigStore;
+import com.wireguard.android.backend.Tunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.backend.Statistics;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.android.util.ObservableSortedKeyedArrayList;
+import com.wireguard.android.util.ObservableSortedKeyedList;
+import com.wireguard.config.Config;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Set;
+
+import java9.util.Comparators;
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+/**
+ * Maintains and mediates changes to the set of available WireGuard tunnels,
+ */
+
+public final class TunnelManager extends BaseObservable {
+ private static final Comparator<String> COMPARATOR = Comparators.<String>thenComparing(
+ String.CASE_INSENSITIVE_ORDER, Comparators.naturalOrder());
+ private static final String KEY_LAST_USED_TUNNEL = "last_used_tunnel";
+ private static final String KEY_RESTORE_ON_BOOT = "restore_on_boot";
+ private static final String KEY_RUNNING_TUNNELS = "enabled_configs";
+
+ private final CompletableFuture<ObservableSortedKeyedList<String, ObservableTunnel>> completableTunnels = new CompletableFuture<>();
+ private final ConfigStore configStore;
+ private final Context context = Application.get();
+ private final ArrayList<CompletableFuture<Void>> delayedLoadRestoreTunnels = new ArrayList<>();
+ private final ObservableSortedKeyedList<String, ObservableTunnel> tunnels = new ObservableSortedKeyedArrayList<>(COMPARATOR);
+ private boolean haveLoaded;
+ @Nullable private ObservableTunnel lastUsedTunnel;
+
+ public TunnelManager(final ConfigStore configStore) {
+ this.configStore = configStore;
+ }
+
+ static CompletionStage<State> getTunnelState(final ObservableTunnel tunnel) {
+ return Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getState(tunnel))
+ .thenApply(tunnel::onStateChanged);
+ }
+
+ static CompletionStage<Statistics> getTunnelStatistics(final ObservableTunnel tunnel) {
+ return Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getStatistics(tunnel))
+ .thenApply(tunnel::onStatisticsChanged);
+ }
+
+ private ObservableTunnel addToList(final String name, @Nullable final Config config, final State state) {
+ final ObservableTunnel tunnel = new ObservableTunnel(this, name, config, state);
+ tunnels.add(tunnel);
+ return tunnel;
+ }
+
+ public CompletionStage<ObservableTunnel> create(final String name, @Nullable final Config config) {
+ if (Tunnel.isNameInvalid(name))
+ return CompletableFuture.failedFuture(new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name)));
+ if (tunnels.containsKey(name)) {
+ final String message = context.getString(R.string.tunnel_error_already_exists, name);
+ return CompletableFuture.failedFuture(new IllegalArgumentException(message));
+ }
+ return Application.getAsyncWorker().supplyAsync(() -> configStore.create(name, config))
+ .thenApply(savedConfig -> addToList(name, savedConfig, State.DOWN));
+ }
+
+ CompletionStage<Void> delete(final ObservableTunnel tunnel) {
+ final State originalState = tunnel.getState();
+ final boolean wasLastUsed = tunnel == lastUsedTunnel;
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ setLastUsedTunnel(null);
+ tunnels.remove(tunnel);
+ return Application.getAsyncWorker().runAsync(() -> {
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.DOWN, null);
+ try {
+ configStore.delete(tunnel.getName());
+ } catch (final Exception e) {
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.UP, tunnel.getConfig());
+ // Re-throw the exception to fail the completion.
+ throw e;
+ }
+ }).whenComplete((x, e) -> {
+ if (e == null)
+ return;
+ // Failure, put the tunnel back.
+ tunnels.add(tunnel);
+ if (wasLastUsed)
+ setLastUsedTunnel(tunnel);
+ });
+ }
+
+ @Bindable
+ @Nullable
+ public ObservableTunnel getLastUsedTunnel() {
+ return lastUsedTunnel;
+ }
+
+ CompletionStage<Config> getTunnelConfig(final ObservableTunnel tunnel) {
+ return Application.getAsyncWorker().supplyAsync(() -> configStore.load(tunnel.getName()))
+ .thenApply(tunnel::onConfigChanged);
+ }
+
+ public CompletableFuture<ObservableSortedKeyedList<String, ObservableTunnel>> getTunnels() {
+ return completableTunnels;
+ }
+
+ public void onCreate() {
+ Application.getAsyncWorker().supplyAsync(configStore::enumerate)
+ .thenAcceptBoth(Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getRunningTunnelNames()), this::onTunnelsLoaded)
+ .whenComplete(ExceptionLoggers.E);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void onTunnelsLoaded(final Iterable<String> present, final Collection<String> running) {
+ for (final String name : present)
+ addToList(name, null, running.contains(name) ? State.UP : State.DOWN);
+ final String lastUsedName = Application.getSharedPreferences().getString(KEY_LAST_USED_TUNNEL, null);
+ if (lastUsedName != null)
+ setLastUsedTunnel(tunnels.get(lastUsedName));
+ final CompletableFuture<Void>[] toComplete;
+ synchronized (delayedLoadRestoreTunnels) {
+ haveLoaded = true;
+ toComplete = delayedLoadRestoreTunnels.toArray(new CompletableFuture[delayedLoadRestoreTunnels.size()]);
+ delayedLoadRestoreTunnels.clear();
+ }
+ restoreState(true).whenComplete((v, t) -> {
+ for (final CompletableFuture<Void> f : toComplete) {
+ if (t == null)
+ f.complete(v);
+ else
+ f.completeExceptionally(t);
+ }
+ });
+
+ completableTunnels.complete(tunnels);
+ }
+
+ public void refreshTunnelStates() {
+ Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().getRunningTunnelNames())
+ .thenAccept(running -> {
+ for (final ObservableTunnel tunnel : tunnels)
+ tunnel.onStateChanged(running.contains(tunnel.getName()) ? State.UP : State.DOWN);
+ })
+ .whenComplete(ExceptionLoggers.E);
+ }
+
+ public CompletionStage<Void> restoreState(final boolean force) {
+ if (!force && !Application.getSharedPreferences().getBoolean(KEY_RESTORE_ON_BOOT, false))
+ return CompletableFuture.completedFuture(null);
+ synchronized (delayedLoadRestoreTunnels) {
+ if (!haveLoaded) {
+ final CompletableFuture<Void> f = new CompletableFuture<>();
+ delayedLoadRestoreTunnels.add(f);
+ return f;
+ }
+ }
+ final Set<String> previouslyRunning = Application.getSharedPreferences().getStringSet(KEY_RUNNING_TUNNELS, null);
+ if (previouslyRunning == null)
+ return CompletableFuture.completedFuture(null);
+ return CompletableFuture.allOf(StreamSupport.stream(tunnels)
+ .filter(tunnel -> previouslyRunning.contains(tunnel.getName()))
+ .map(tunnel -> setTunnelState(tunnel, State.UP))
+ .toArray(CompletableFuture[]::new));
+ }
+
+ public void saveState() {
+ final Set<String> runningTunnels = StreamSupport.stream(tunnels)
+ .filter(tunnel -> tunnel.getState() == State.UP)
+ .map(ObservableTunnel::getName)
+ .collect(Collectors.toUnmodifiableSet());
+ Application.getSharedPreferences().edit().putStringSet(KEY_RUNNING_TUNNELS, runningTunnels).apply();
+ }
+
+ private void setLastUsedTunnel(@Nullable final ObservableTunnel tunnel) {
+ if (tunnel == lastUsedTunnel)
+ return;
+ lastUsedTunnel = tunnel;
+ notifyPropertyChanged(BR.lastUsedTunnel);
+ if (tunnel != null)
+ Application.getSharedPreferences().edit().putString(KEY_LAST_USED_TUNNEL, tunnel.getName()).apply();
+ else
+ Application.getSharedPreferences().edit().remove(KEY_LAST_USED_TUNNEL).apply();
+ }
+
+ CompletionStage<Config> setTunnelConfig(final ObservableTunnel tunnel, final Config config) {
+ return Application.getAsyncWorker().supplyAsync(() -> {
+ Application.getBackend().setState(tunnel, tunnel.getState(), config);
+ return configStore.save(tunnel.getName(), config);
+ }).thenApply(tunnel::onConfigChanged);
+ }
+
+ CompletionStage<String> setTunnelName(final ObservableTunnel tunnel, final String name) {
+ if (Tunnel.isNameInvalid(name))
+ return CompletableFuture.failedFuture(new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_name)));
+ if (tunnels.containsKey(name)) {
+ final String message = context.getString(R.string.tunnel_error_already_exists, name);
+ return CompletableFuture.failedFuture(new IllegalArgumentException(message));
+ }
+ final State originalState = tunnel.getState();
+ final boolean wasLastUsed = tunnel == lastUsedTunnel;
+ // Make sure nothing touches the tunnel.
+ if (wasLastUsed)
+ setLastUsedTunnel(null);
+ tunnels.remove(tunnel);
+ return Application.getAsyncWorker().supplyAsync(() -> {
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.DOWN, null);
+ configStore.rename(tunnel.getName(), name);
+ final String newName = tunnel.onNameChanged(name);
+ if (originalState == State.UP)
+ Application.getBackend().setState(tunnel, State.UP, tunnel.getConfig());
+ return newName;
+ }).whenComplete((newName, e) -> {
+ // On failure, we don't know what state the tunnel might be in. Fix that.
+ if (e != null)
+ getTunnelState(tunnel);
+ // Add the tunnel back to the manager, under whatever name it thinks it has.
+ tunnels.add(tunnel);
+ if (wasLastUsed)
+ setLastUsedTunnel(tunnel);
+ });
+ }
+
+ CompletionStage<State> setTunnelState(final ObservableTunnel tunnel, final State state) {
+ // Ensure the configuration is loaded before trying to use it.
+ return tunnel.getConfigAsync().thenCompose(config ->
+ Application.getAsyncWorker().supplyAsync(() -> Application.getBackend().setState(tunnel, state, config))
+ ).whenComplete((newState, e) -> {
+ // Ensure onStateChanged is always called (failure or not), and with the correct state.
+ tunnel.onStateChanged(e == null ? newState : tunnel.getState());
+ if (e == null && newState == State.UP)
+ setLastUsedTunnel(tunnel);
+ saveState();
+ });
+ }
+
+ public static final class IntentReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, @Nullable final Intent intent) {
+ final TunnelManager manager = Application.getTunnelManager();
+ if (intent == null)
+ return;
+ final String action = intent.getAction();
+ if (action == null)
+ return;
+
+ if ("com.wireguard.android.action.REFRESH_TUNNEL_STATES".equals(action)) {
+ manager.refreshTunnelStates();
+ return;
+ }
+
+ /* We disable the below, for now, as the security model of allowing this
+ * might take a bit more consideration.
+ */
+ if (true)
+ return;
+
+ final State state;
+ if ("com.wireguard.android.action.SET_TUNNEL_UP".equals(action))
+ state = State.UP;
+ else if ("com.wireguard.android.action.SET_TUNNEL_DOWN".equals(action))
+ state = State.DOWN;
+ else
+ return;
+
+ final String tunnelName = intent.getStringExtra("tunnel");
+ if (tunnelName == null)
+ return;
+ manager.getTunnels().thenAccept(tunnels -> {
+ final ObservableTunnel tunnel = tunnels.get(tunnelName);
+ if (tunnel == null)
+ return;
+ manager.setTunnelState(tunnel, state);
+ });
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java b/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java
new file mode 100644
index 00000000..565854b4
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/LogExporterPreference.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.preference.Preference;
+
+import android.util.AttributeSet;
+import android.util.Log;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.util.DownloadsFileSaver;
+import com.wireguard.android.util.DownloadsFileSaver.DownloadsFile;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.FragmentUtils;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+
+/**
+ * Preference implementing a button that asynchronously exports logs.
+ */
+
+public class LogExporterPreference extends Preference {
+ private static final String TAG = "WireGuard/" + LogExporterPreference.class.getSimpleName();
+
+ @Nullable private String exportedFilePath;
+
+ public LogExporterPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ private void exportLog() {
+ Application.getAsyncWorker().supplyAsync(() -> {
+ DownloadsFile outputFile = DownloadsFileSaver.save(getContext(), "wireguard-log.txt", "text/plain", true);
+ try {
+ final Process process = Runtime.getRuntime().exec(new String[]{
+ "logcat", "-b", "all", "-d", "-v", "threadtime", "*:V"});
+ try (final BufferedReader stdout = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ final BufferedReader stderr = new BufferedReader(new InputStreamReader(process.getErrorStream())))
+ {
+ String line;
+ while ((line = stdout.readLine()) != null) {
+ outputFile.getOutputStream().write(line.getBytes());
+ outputFile.getOutputStream().write('\n');
+ }
+ outputFile.getOutputStream().close();
+ stdout.close();
+ if (process.waitFor() != 0) {
+ final StringBuilder errors = new StringBuilder();
+ errors.append(R.string.logcat_error);
+ while ((line = stderr.readLine()) != null)
+ errors.append(line);
+ throw new Exception(errors.toString());
+ }
+ }
+ } catch (final Exception e) {
+ outputFile.delete();
+ throw e;
+ }
+ return outputFile.getFileName();
+ }).whenComplete(this::exportLogComplete);
+ }
+
+ private void exportLogComplete(final String filePath, @Nullable final Throwable throwable) {
+ if (throwable != null) {
+ final String error = ErrorMessages.get(throwable);
+ final String message = getContext().getString(R.string.log_export_error, error);
+ Log.e(TAG, message, throwable);
+ Snackbar.make(
+ FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content),
+ message, Snackbar.LENGTH_LONG).show();
+ setEnabled(true);
+ } else {
+ exportedFilePath = filePath;
+ notifyChanged();
+ }
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return exportedFilePath == null ?
+ getContext().getString(R.string.log_export_summary) :
+ getContext().getString(R.string.log_export_success, exportedFilePath);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.log_export_title);
+ }
+
+ @Override
+ protected void onClick() {
+ FragmentUtils.getPrefActivity(this).ensurePermissions(
+ new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
+ (permissions, granted) -> {
+ if (granted.length > 0 && granted[0] == PackageManager.PERMISSION_GRANTED) {
+ setEnabled(false);
+ exportLog();
+ }
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java b/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java
new file mode 100644
index 00000000..aac649dd
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ModuleDownloaderPreference.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright © 2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.content.Context;
+import android.content.Intent;
+import android.system.OsConstants;
+import android.util.AttributeSet;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.ModuleLoader;
+import com.wireguard.android.util.ToolsInstaller;
+
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+
+public class ModuleDownloaderPreference extends Preference {
+ private State state = State.INITIAL;
+
+ public ModuleDownloaderPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return getContext().getString(state.messageResourceId);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.module_installer_title);
+ }
+
+ @Override
+ protected void onClick() {
+ setState(State.WORKING);
+ Application.getAsyncWorker().supplyAsync(Application.getModuleLoader()::download).whenComplete(this::onDownloadResult);
+ }
+
+ private void onDownloadResult(final Integer result, @Nullable final Throwable throwable) {
+ if (throwable != null) {
+ setState(State.FAILURE);
+ Toast.makeText(getContext(), ErrorMessages.get(throwable), Toast.LENGTH_LONG).show();
+ } else if (result == OsConstants.ENOENT)
+ setState(State.NOTFOUND);
+ else if (result == OsConstants.EXIT_SUCCESS) {
+ setState(State.SUCCESS);
+ Application.getAsyncWorker().runAsync(() -> {
+ Thread.sleep(1000 * 5);
+ Intent i = getContext().getPackageManager().getLaunchIntentForPackage(getContext().getPackageName());
+ if (i == null)
+ return;
+ i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ Application.get().startActivity(i);
+ System.exit(0);
+ });
+ } else
+ setState(State.FAILURE);
+ }
+
+ private void setState(final State state) {
+ if (this.state == state)
+ return;
+ this.state = state;
+ if (isEnabled() != state.shouldEnableView)
+ setEnabled(state.shouldEnableView);
+ notifyChanged();
+ }
+
+ private enum State {
+ INITIAL(R.string.module_installer_initial, true),
+ FAILURE(R.string.module_installer_error, true),
+ WORKING(R.string.module_installer_working, false),
+ SUCCESS(R.string.module_installer_success, false),
+ NOTFOUND(R.string.module_installer_not_found, false);
+
+ private final int messageResourceId;
+ private final boolean shouldEnableView;
+
+ State(final int messageResourceId, final boolean shouldEnableView) {
+ this.messageResourceId = messageResourceId;
+ this.shouldEnableView = shouldEnableView;
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java
new file mode 100644
index 00000000..78a7497b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ToolsInstallerPreference.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.content.Context;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import android.util.AttributeSet;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.util.ToolsInstaller;
+
+/**
+ * Preference implementing a button that asynchronously runs {@code ToolsInstaller} and displays the
+ * result as the preference summary.
+ */
+
+public class ToolsInstallerPreference extends Preference {
+ private State state = State.INITIAL;
+
+ public ToolsInstallerPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return getContext().getString(state.messageResourceId);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.tools_installer_title);
+ }
+
+ @Override
+ public void onAttached() {
+ super.onAttached();
+ Application.getAsyncWorker().supplyAsync(Application.getToolsInstaller()::areInstalled).whenComplete(this::onCheckResult);
+ }
+
+ private void onCheckResult(final int state, @Nullable final Throwable throwable) {
+ if (throwable != null || state == ToolsInstaller.ERROR)
+ setState(State.INITIAL);
+ else if ((state & ToolsInstaller.YES) == ToolsInstaller.YES)
+ setState(State.ALREADY);
+ else if ((state & (ToolsInstaller.MAGISK | ToolsInstaller.NO)) == (ToolsInstaller.MAGISK | ToolsInstaller.NO))
+ setState(State.INITIAL_MAGISK);
+ else if ((state & (ToolsInstaller.SYSTEM | ToolsInstaller.NO)) == (ToolsInstaller.SYSTEM | ToolsInstaller.NO))
+ setState(State.INITIAL_SYSTEM);
+ else
+ setState(State.INITIAL);
+ }
+
+ @Override
+ protected void onClick() {
+ setState(State.WORKING);
+ Application.getAsyncWorker().supplyAsync(Application.getToolsInstaller()::install).whenComplete(this::onInstallResult);
+ }
+
+ private void onInstallResult(final Integer result, @Nullable final Throwable throwable) {
+ if (throwable != null)
+ setState(State.FAILURE);
+ else if ((result & (ToolsInstaller.YES | ToolsInstaller.MAGISK)) == (ToolsInstaller.YES | ToolsInstaller.MAGISK))
+ setState(State.SUCCESS_MAGISK);
+ else if ((result & (ToolsInstaller.YES | ToolsInstaller.SYSTEM)) == (ToolsInstaller.YES | ToolsInstaller.SYSTEM))
+ setState(State.SUCCESS_SYSTEM);
+ else
+ setState(State.FAILURE);
+ }
+
+ private void setState(final State state) {
+ if (this.state == state)
+ return;
+ this.state = state;
+ if (isEnabled() != state.shouldEnableView)
+ setEnabled(state.shouldEnableView);
+ notifyChanged();
+ }
+
+ private enum State {
+ INITIAL(R.string.tools_installer_initial, true),
+ ALREADY(R.string.tools_installer_already, false),
+ FAILURE(R.string.tools_installer_failure, true),
+ WORKING(R.string.tools_installer_working, false),
+ INITIAL_SYSTEM(R.string.tools_installer_initial_system, true),
+ SUCCESS_SYSTEM(R.string.tools_installer_success_system, false),
+ INITIAL_MAGISK(R.string.tools_installer_initial_magisk, true),
+ SUCCESS_MAGISK(R.string.tools_installer_success_magisk, false);
+
+ private final int messageResourceId;
+ private final boolean shouldEnableView;
+
+ State(final int messageResourceId, final boolean shouldEnableView) {
+ this.messageResourceId = messageResourceId;
+ this.shouldEnableView = shouldEnableView;
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java
new file mode 100644
index 00000000..7e95a8ae
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/VersionPreference.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+import android.util.AttributeSet;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.BuildConfig;
+import com.wireguard.android.R;
+import com.wireguard.android.backend.Backend;
+import com.wireguard.android.backend.GoBackend;
+import com.wireguard.android.backend.WgQuickBackend;
+
+import java.util.Locale;
+
+public class VersionPreference extends Preference {
+ @Nullable private String versionSummary;
+
+ private String getBackendPrettyName(final Context context, final Backend backend) {
+ if (backend instanceof GoBackend)
+ return context.getString(R.string.type_name_kernel_module);
+ if (backend instanceof WgQuickBackend)
+ return context.getString(R.string.type_name_go_userspace);
+ return "";
+ }
+
+ public VersionPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ Application.getBackendAsync().thenAccept(backend -> {
+ versionSummary = getContext().getString(R.string.version_summary_checking, getBackendPrettyName(context, backend).toLowerCase(Locale.ENGLISH));
+ Application.getAsyncWorker().supplyAsync(backend::getVersion).whenComplete((version, exception) -> {
+ versionSummary = exception == null
+ ? getContext().getString(R.string.version_summary, getBackendPrettyName(context, backend), version)
+ : getContext().getString(R.string.version_summary_unknown, getBackendPrettyName(context, backend).toLowerCase(Locale.ENGLISH));
+ notifyChanged();
+ });
+ });
+ }
+
+ @Nullable
+ @Override
+ public CharSequence getSummary() {
+ return versionSummary;
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.version_title, BuildConfig.VERSION_NAME);
+ }
+
+ @Override
+ protected void onClick() {
+ final Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("https://www.wireguard.com/"));
+ try {
+ getContext().startActivity(intent);
+ } catch (final ActivityNotFoundException ignored) {
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java
new file mode 100644
index 00000000..3af412a5
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/preference/ZipExporterPreference.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.preference;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.preference.Preference;
+import android.util.AttributeSet;
+import android.util.Log;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.util.DownloadsFileSaver;
+import com.wireguard.android.util.DownloadsFileSaver.DownloadsFile;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.FragmentUtils;
+import com.wireguard.config.Config;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import java9.util.concurrent.CompletableFuture;
+
+/**
+ * Preference implementing a button that asynchronously exports config zips.
+ */
+
+public class ZipExporterPreference extends Preference {
+ private static final String TAG = "WireGuard/" + ZipExporterPreference.class.getSimpleName();
+
+ @Nullable private String exportedFilePath;
+
+ public ZipExporterPreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ private void exportZip() {
+ Application.getTunnelManager().getTunnels().thenAccept(this::exportZip);
+ }
+
+ private void exportZip(final List<ObservableTunnel> tunnels) {
+ final List<CompletableFuture<Config>> futureConfigs = new ArrayList<>(tunnels.size());
+ for (final ObservableTunnel tunnel : tunnels)
+ futureConfigs.add(tunnel.getConfigAsync().toCompletableFuture());
+ if (futureConfigs.isEmpty()) {
+ exportZipComplete(null, new IllegalArgumentException(
+ getContext().getString(R.string.no_tunnels_error)));
+ return;
+ }
+ CompletableFuture.allOf(futureConfigs.toArray(new CompletableFuture[futureConfigs.size()]))
+ .whenComplete((ignored1, exception) -> Application.getAsyncWorker().supplyAsync(() -> {
+ if (exception != null)
+ throw exception;
+ DownloadsFile outputFile = DownloadsFileSaver.save(getContext(), "wireguard-export.zip", "application/zip", true);
+ try (ZipOutputStream zip = new ZipOutputStream(outputFile.getOutputStream())) {
+ for (int i = 0; i < futureConfigs.size(); ++i) {
+ zip.putNextEntry(new ZipEntry(tunnels.get(i).getName() + ".conf"));
+ zip.write(futureConfigs.get(i).getNow(null).
+ toWgQuickString().getBytes(StandardCharsets.UTF_8));
+ }
+ zip.closeEntry();
+ } catch (final Exception e) {
+ outputFile.delete();
+ throw e;
+ }
+ return outputFile.getFileName();
+ }).whenComplete(this::exportZipComplete));
+ }
+
+ private void exportZipComplete(@Nullable final String filePath, @Nullable final Throwable throwable) {
+ if (throwable != null) {
+ final String error = ErrorMessages.get(throwable);
+ final String message = getContext().getString(R.string.zip_export_error, error);
+ Log.e(TAG, message, throwable);
+ Snackbar.make(
+ FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content),
+ message, Snackbar.LENGTH_LONG).show();
+ setEnabled(true);
+ } else {
+ exportedFilePath = filePath;
+ notifyChanged();
+ }
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return exportedFilePath == null ?
+ getContext().getString(R.string.zip_export_summary) :
+ getContext().getString(R.string.zip_export_success, exportedFilePath);
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return getContext().getString(R.string.zip_export_title);
+ }
+
+ @Override
+ protected void onClick() {
+ FragmentUtils.getPrefActivity(this).ensurePermissions(
+ new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
+ (permissions, granted) -> {
+ if (granted.length > 0 && granted[0] == PackageManager.PERMISSION_GRANTED) {
+ setEnabled(false);
+ exportZip();
+ }
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt b/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt
new file mode 100644
index 00000000..52a19657
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/ui/EdgeToEdge.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright © 2017-2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.ui
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.marginBottom
+import androidx.core.view.marginLeft
+import androidx.core.view.marginRight
+import androidx.core.view.marginTop
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
+
+/**
+ * A utility for edge-to-edge display. It provides several features needed to make the app
+ * displayed edge-to-edge on Android Q with gestural navigation.
+ */
+
+object EdgeToEdge {
+
+ @JvmStatic
+ fun setUpRoot(root: ViewGroup) {
+ root.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ }
+
+ @JvmStatic
+ fun setUpScrollingContent(scrollingContent: ViewGroup, fab: ExtendedFloatingActionButton?) {
+ val originalPaddingLeft = scrollingContent.paddingLeft
+ val originalPaddingRight = scrollingContent.paddingRight
+ val originalPaddingBottom = scrollingContent.paddingBottom
+
+ val fabPaddingBottom = fab?.height ?: 0
+
+ val originalMarginTop = scrollingContent.marginTop
+
+ scrollingContent.setOnApplyWindowInsetsListener { _, windowInsets ->
+ scrollingContent.updatePadding(
+ left = originalPaddingLeft + windowInsets.systemWindowInsetLeft,
+ right = originalPaddingRight + windowInsets.systemWindowInsetRight,
+ bottom = originalPaddingBottom + fabPaddingBottom + windowInsets.systemWindowInsetBottom
+ )
+ scrollingContent.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+ topMargin = originalMarginTop + windowInsets.systemWindowInsetTop
+ }
+ windowInsets
+ }
+ }
+
+ @JvmStatic
+ fun setUpFAB(fab: ExtendedFloatingActionButton) {
+ val originalMarginLeft = fab.marginLeft
+ val originalMarginRight = fab.marginRight
+ val originalMarginBottom = fab.marginBottom
+ fab.setOnApplyWindowInsetsListener { _, windowInsets ->
+ fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+ leftMargin = originalMarginLeft + windowInsets.systemWindowInsetLeft
+ rightMargin = originalMarginRight + windowInsets.systemWindowInsetRight
+ bottomMargin = originalMarginBottom + windowInsets.systemWindowInsetBottom
+ }
+ windowInsets
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java
new file mode 100644
index 00000000..0df5e96a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ClipboardUtils.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import com.google.android.material.snackbar.Snackbar;
+import android.view.View;
+import android.widget.TextView;
+
+/**
+ * Standalone utilities for interacting with the system clipboard.
+ */
+
+public final class ClipboardUtils {
+ private ClipboardUtils() {
+ // Prevent instantiation
+ }
+
+ public static void copyTextView(final View view) {
+ if (!(view instanceof TextView))
+ return;
+ final CharSequence text = ((TextView) view).getText();
+ if (text == null || text.length() == 0)
+ return;
+ final Object service = view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ if (!(service instanceof ClipboardManager))
+ return;
+ final CharSequence description = view.getContentDescription();
+ ((ClipboardManager) service).setPrimaryClip(ClipData.newPlainText(description, text));
+ Snackbar.make(view, description + " copied to clipboard", Snackbar.LENGTH_LONG).show();
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java
new file mode 100644
index 00000000..7db46fa9
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/DownloadsFileSaver.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright © 2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.provider.MediaStore.MediaColumns;
+
+import com.wireguard.android.R;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+public class DownloadsFileSaver {
+
+ public static class DownloadsFile {
+ private Context context;
+ private OutputStream outputStream;
+ private String fileName;
+ private Uri uri;
+
+ private DownloadsFile(final Context context, final OutputStream outputStream, final String fileName, final Uri uri) {
+ this.context = context;
+ this.outputStream = outputStream;
+ this.fileName = fileName;
+ this.uri = uri;
+ }
+
+ public OutputStream getOutputStream() { return outputStream; }
+ public String getFileName() { return fileName; }
+
+ public void delete() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ context.getContentResolver().delete(uri, null, null);
+ else
+ new File(fileName).delete();
+ }
+ }
+
+ public static DownloadsFile save(final Context context, final String name, final String mimeType, final boolean overwriteExisting) throws Exception {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ final ContentResolver contentResolver = context.getContentResolver();
+ if (overwriteExisting)
+ contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), new String[]{name});
+ final ContentValues contentValues = new ContentValues();
+ contentValues.put(MediaColumns.DISPLAY_NAME, name);
+ contentValues.put(MediaColumns.MIME_TYPE, mimeType);
+ final Uri contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
+ if (contentUri == null)
+ throw new IOException(context.getString(R.string.create_downloads_file_error));
+ final OutputStream contentStream = contentResolver.openOutputStream(contentUri);
+ if (contentStream == null)
+ throw new IOException(context.getString(R.string.create_downloads_file_error));
+ @SuppressWarnings("deprecation")
+ Cursor cursor = contentResolver.query(contentUri, new String[]{MediaColumns.DATA}, null, null, null);
+ String path = null;
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst())
+ path = cursor.getString(0);
+ } finally {
+ cursor.close();
+ }
+ }
+ if (path == null) {
+ path = "Download/";
+ cursor = contentResolver.query(contentUri, new String[]{MediaColumns.DISPLAY_NAME}, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst())
+ path += cursor.getString(0);
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ return new DownloadsFile(context, contentStream, path, contentUri);
+ } else {
+ @SuppressWarnings("deprecation")
+ final File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ final File file = new File(path, name);
+ if (!path.isDirectory() && !path.mkdirs())
+ throw new IOException(context.getString(R.string.create_output_dir_error));
+ return new DownloadsFile(context, new FileOutputStream(file), file.getAbsolutePath(), null);
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java
new file mode 100644
index 00000000..481a6ffb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ErrorMessages.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import android.content.res.Resources;
+import android.os.RemoteException;
+
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.backend.BackendException;
+import com.wireguard.android.util.RootShell.RootShellException;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.BadConfigException.Location;
+import com.wireguard.config.InetEndpoint;
+import com.wireguard.config.InetNetwork;
+import com.wireguard.config.ParseException;
+import com.wireguard.crypto.Key.Format;
+import com.wireguard.crypto.KeyFormatException;
+import com.wireguard.crypto.KeyFormatException.Type;
+
+import java.net.InetAddress;
+import java.util.EnumMap;
+import java.util.Map;
+
+import java9.util.Maps;
+
+public final class ErrorMessages {
+ private static final Map<BadConfigException.Reason, Integer> BCE_REASON_MAP = new EnumMap<>(Maps.of(
+ BadConfigException.Reason.INVALID_KEY, R.string.bad_config_reason_invalid_key,
+ BadConfigException.Reason.INVALID_NUMBER, R.string.bad_config_reason_invalid_number,
+ BadConfigException.Reason.INVALID_VALUE, R.string.bad_config_reason_invalid_value,
+ BadConfigException.Reason.MISSING_ATTRIBUTE, R.string.bad_config_reason_missing_attribute,
+ BadConfigException.Reason.MISSING_SECTION, R.string.bad_config_reason_missing_section,
+ BadConfigException.Reason.MISSING_VALUE, R.string.bad_config_reason_missing_value,
+ BadConfigException.Reason.SYNTAX_ERROR, R.string.bad_config_reason_syntax_error,
+ BadConfigException.Reason.UNKNOWN_ATTRIBUTE, R.string.bad_config_reason_unknown_attribute,
+ BadConfigException.Reason.UNKNOWN_SECTION, R.string.bad_config_reason_unknown_section
+ ));
+ private static final Map<BackendException.Reason, Integer> BE_REASON_MAP = new EnumMap<>(Maps.of(
+ BackendException.Reason.UNKNOWN_KERNEL_MODULE_NAME, R.string.module_version_error,
+ BackendException.Reason.WG_QUICK_CONFIG_ERROR_CODE, R.string.tunnel_config_error,
+ BackendException.Reason.TUNNEL_MISSING_CONFIG, R.string.no_config_error,
+ BackendException.Reason.VPN_NOT_AUTHORIZED, R.string.vpn_not_authorized_error,
+ BackendException.Reason.UNABLE_TO_START_VPN, R.string.vpn_start_error,
+ BackendException.Reason.TUN_CREATION_ERROR, R.string.tun_create_error,
+ BackendException.Reason.GO_ACTIVATION_ERROR_CODE, R.string.tunnel_on_error
+ ));
+ private static final Map<RootShellException.Reason, Integer> RSE_REASON_MAP = new EnumMap<>(Maps.of(
+ RootShellException.Reason.NO_ROOT_ACCESS, R.string.error_root,
+ RootShellException.Reason.SHELL_MARKER_COUNT_ERROR, R.string.shell_marker_count_error,
+ RootShellException.Reason.SHELL_EXIT_STATUS_READ_ERROR, R.string.shell_exit_status_read_error,
+ RootShellException.Reason.SHELL_START_ERROR, R.string.shell_start_error,
+ RootShellException.Reason.CREATE_BIN_DIR_ERROR, R.string.create_bin_dir_error,
+ RootShellException.Reason.CREATE_TEMP_DIR_ERROR, R.string.create_temp_dir_error
+ ));
+ private static final Map<Format, Integer> KFE_FORMAT_MAP = new EnumMap<>(Maps.of(
+ Format.BASE64, R.string.key_length_explanation_base64,
+ Format.BINARY, R.string.key_length_explanation_binary,
+ Format.HEX, R.string.key_length_explanation_hex
+ ));
+ private static final Map<Type, Integer> KFE_TYPE_MAP = new EnumMap<>(Maps.of(
+ Type.CONTENTS, R.string.key_contents_error,
+ Type.LENGTH, R.string.key_length_error
+ ));
+ private static final Map<Class, Integer> PE_CLASS_MAP = Maps.of(
+ InetAddress.class, R.string.parse_error_inet_address,
+ InetEndpoint.class, R.string.parse_error_inet_endpoint,
+ InetNetwork.class, R.string.parse_error_inet_network,
+ Integer.class, R.string.parse_error_integer
+ );
+
+ private ErrorMessages() {
+ // Prevent instantiation
+ }
+
+ public static String get(@Nullable final Throwable throwable) {
+ final Resources resources = Application.get().getResources();
+ if (throwable == null)
+ return resources.getString(R.string.unknown_error);
+ final Throwable rootCause = rootCause(throwable);
+ final String message;
+ if (rootCause instanceof BadConfigException) {
+ final BadConfigException bce = (BadConfigException) rootCause;
+ final String reason = getBadConfigExceptionReason(resources, bce);
+ final String context = bce.getLocation() == Location.TOP_LEVEL ?
+ resources.getString(R.string.bad_config_context_top_level,
+ bce.getSection().getName()) :
+ resources.getString(R.string.bad_config_context,
+ bce.getSection().getName(),
+ bce.getLocation().getName());
+ final String explanation = getBadConfigExceptionExplanation(resources, bce);
+ message = resources.getString(R.string.bad_config_error, reason, context) + explanation;
+ } else if (rootCause instanceof BackendException) {
+ final BackendException be = (BackendException) rootCause;
+ message = resources.getString(BE_REASON_MAP.get(be.getReason()), be.getFormat());
+ } else if (rootCause instanceof RootShellException) {
+ final RootShellException rse = (RootShellException) rootCause;
+ message = resources.getString(RSE_REASON_MAP.get(rse.getReason()), rse.getFormat());
+ } else if (rootCause.getMessage() != null) {
+ message = rootCause.getMessage();
+ } else {
+ final String errorType = rootCause.getClass().getSimpleName();
+ message = resources.getString(R.string.generic_error, errorType);
+ }
+ return message;
+ }
+
+ private static String getBadConfigExceptionExplanation(final Resources resources,
+ final BadConfigException bce) {
+ if (bce.getCause() instanceof KeyFormatException) {
+ final KeyFormatException kfe = (KeyFormatException) bce.getCause();
+ if (kfe.getType() == Type.LENGTH)
+ return resources.getString(KFE_FORMAT_MAP.get(kfe.getFormat()));
+ } else if (bce.getCause() instanceof ParseException) {
+ final ParseException pe = (ParseException) bce.getCause();
+ if (pe.getMessage() != null)
+ return ": " + pe.getMessage();
+ } else if (bce.getLocation() == Location.LISTEN_PORT) {
+ return resources.getString(R.string.bad_config_explanation_udp_port);
+ } else if (bce.getLocation() == Location.MTU) {
+ return resources.getString(R.string.bad_config_explanation_positive_number);
+ } else if (bce.getLocation() == Location.PERSISTENT_KEEPALIVE) {
+ return resources.getString(R.string.bad_config_explanation_pka);
+ }
+ return "";
+ }
+
+ private static String getBadConfigExceptionReason(final Resources resources,
+ final BadConfigException bce) {
+ if (bce.getCause() instanceof KeyFormatException) {
+ final KeyFormatException kfe = (KeyFormatException) bce.getCause();
+ return resources.getString(KFE_TYPE_MAP.get(kfe.getType()));
+ } else if (bce.getCause() instanceof ParseException) {
+ final ParseException pe = (ParseException) bce.getCause();
+ final String type = resources.getString(PE_CLASS_MAP.containsKey(pe.getParsingClass()) ?
+ PE_CLASS_MAP.get(pe.getParsingClass()) : R.string.parse_error_generic);
+ return resources.getString(R.string.parse_error_reason, type, pe.getText());
+ }
+ return resources.getString(BCE_REASON_MAP.get(bce.getReason()), bce.getText());
+ }
+
+ private static Throwable rootCause(final Throwable throwable) {
+ Throwable cause = throwable;
+ while (cause.getCause() != null) {
+ if (cause instanceof BadConfigException || cause instanceof BackendException ||
+ cause instanceof RootShellException)
+ break;
+ final Throwable nextCause = cause.getCause();
+ if (nextCause instanceof RemoteException)
+ break;
+ cause = nextCause;
+ }
+ return cause;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java
new file mode 100644
index 00000000..5c7a38c0
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ExceptionLoggers.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.annotation.Nullable;
+import android.util.Log;
+
+import java9.util.function.BiConsumer;
+
+/**
+ * Helpers for logging exceptions from asynchronous tasks. These can be passed to
+ * {@code CompletionStage.whenComplete()} at the end of an asynchronous future chain.
+ */
+
+public enum ExceptionLoggers implements BiConsumer<Object, Throwable> {
+ D(Log.DEBUG),
+ E(Log.ERROR);
+
+ private static final String TAG = "WireGuard/" + ExceptionLoggers.class.getSimpleName();
+ private final int priority;
+
+ ExceptionLoggers(final int priority) {
+ this.priority = priority;
+ }
+
+ @Override
+ public void accept(final Object result, @Nullable final Throwable throwable) {
+ if (throwable != null)
+ Log.println(Log.ERROR, TAG, Log.getStackTraceString(throwable));
+ else if (priority <= Log.DEBUG)
+ Log.println(priority, TAG, "Future completed successfully");
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/Extensions.kt b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
new file mode 100644
index 00000000..6b528a85
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util
+
+import android.content.Context
+import android.util.TypedValue
+import androidx.annotation.AttrRes
+
+fun Context.resolveAttribute(@AttrRes attrRes: Int): Int {
+ val typedValue = TypedValue()
+ theme.resolveAttribute(attrRes, typedValue, true)
+ return typedValue.data
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java
new file mode 100644
index 00000000..5fb9a3bc
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/FragmentUtils.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.util;
+
+import android.content.Context;
+import androidx.preference.Preference;
+import android.view.ContextThemeWrapper;
+
+import com.wireguard.android.activity.SettingsActivity;
+
+public final class FragmentUtils {
+ private FragmentUtils() {
+ // Prevent instantiation
+ }
+
+ public static SettingsActivity getPrefActivity(final Preference preference) {
+ final Context context = preference.getContext();
+ if (context instanceof ContextThemeWrapper) {
+ if (context instanceof SettingsActivity) {
+ return ((SettingsActivity) context);
+ }
+ }
+ return null;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java b/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java
new file mode 100644
index 00000000..bf094a5e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ModuleLoader.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright © 2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import android.content.Context;
+import android.system.OsConstants;
+import android.util.Base64;
+
+import com.wireguard.android.util.RootShell.RootShellException;
+
+import net.i2p.crypto.eddsa.EdDSAEngine;
+import net.i2p.crypto.eddsa.EdDSAPublicKey;
+import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable;
+import net.i2p.crypto.eddsa.spec.EdDSAParameterSpec;
+import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidParameterException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.Signature;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nullable;
+
+public class ModuleLoader {
+ private static final String MODULE_PUBLIC_KEY_BASE64 = "RWRmHuT9PSqtwfsLtEx+QS06BJtLgFYteL9WCNjH7yuyu5Y1DieSN7If";
+ private static final String MODULE_LIST_URL = "https://download.wireguard.com/android-module/modules.txt.sig";
+ private static final String MODULE_URL = "https://download.wireguard.com/android-module/%s";
+ private static final String MODULE_NAME = "wireguard-%s.ko";
+
+ private final RootShell rootShell;
+ private final String userAgent;
+ private final File moduleDir;
+ private final File tmpDir;
+
+ public ModuleLoader(final Context context, final RootShell rootShell, final String userAgent) {
+ moduleDir = new File(context.getCacheDir(), "kmod");
+ tmpDir = new File(context.getCacheDir(), "tmp");
+ this.rootShell = rootShell;
+ this.userAgent = userAgent;
+ }
+
+ public boolean moduleMightExist() {
+ return moduleDir.exists() && moduleDir.isDirectory();
+ }
+
+ public void loadModule() throws IOException, RootShellException {
+ rootShell.run(null, String.format("insmod \"%s/wireguard-$(sha256sum /proc/version|cut -d ' ' -f 1).ko\"", moduleDir.getAbsolutePath()));
+ }
+
+ public static boolean isModuleLoaded() {
+ return new File("/sys/module/wireguard").exists();
+ }
+
+ private static final class Sha256Digest {
+ private byte[] bytes;
+ private Sha256Digest(final String hex) {
+ if (hex.length() != 64)
+ throw new InvalidParameterException("SHA256 hashes must be 32 bytes long");
+ bytes = new byte[32];
+ for (int i = 0; i < 32; ++i)
+ bytes[i] = (byte)Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
+ }
+ }
+
+ @Nullable
+ private Map<String, Sha256Digest> verifySignedHashes(final String signifyDigest) {
+ final byte[] publicKeyBytes = Base64.decode(MODULE_PUBLIC_KEY_BASE64, Base64.DEFAULT);
+
+ if (publicKeyBytes == null || publicKeyBytes.length != 32 + 10 || publicKeyBytes[0] != 'E' || publicKeyBytes[1] != 'd')
+ return null;
+
+ final String[] lines = signifyDigest.split("\n", 3);
+ if (lines.length != 3)
+ return null;
+ if (!lines[0].startsWith("untrusted comment: "))
+ return null;
+
+ final byte[] signatureBytes = Base64.decode(lines[1], Base64.DEFAULT);
+ if (signatureBytes == null || signatureBytes.length != 64 + 10)
+ return null;
+ for (int i = 0; i < 10; ++i) {
+ if (signatureBytes[i] != publicKeyBytes[i])
+ return null;
+ }
+
+ try {
+ EdDSAParameterSpec parameterSpec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519);
+ Signature signature = new EdDSAEngine(MessageDigest.getInstance(parameterSpec.getHashAlgorithm()));
+ byte[] rawPublicKeyBytes = new byte[32];
+ System.arraycopy(publicKeyBytes, 10, rawPublicKeyBytes, 0, 32);
+ signature.initVerify(new EdDSAPublicKey(new EdDSAPublicKeySpec(rawPublicKeyBytes, parameterSpec)));
+ signature.update(lines[2].getBytes(StandardCharsets.UTF_8));
+ if (!signature.verify(signatureBytes, 10, 64))
+ return null;
+ } catch (final Exception ignored) {
+ return null;
+ }
+
+ Map<String, Sha256Digest> hashes = new HashMap<>();
+ for (final String line : lines[2].split("\n")) {
+ final String[] components = line.split(" ", 2);
+ if (components.length != 2)
+ return null;
+ try {
+ hashes.put(components[1], new Sha256Digest(components[0]));
+ } catch (final Exception ignored) {
+ return null;
+ }
+ }
+ return hashes;
+ }
+
+ public Integer download() throws IOException, RootShellException, NoSuchAlgorithmException {
+ final List<String> output = new ArrayList<>();
+ rootShell.run(output, "sha256sum /proc/version|cut -d ' ' -f 1");
+ if (output.size() != 1 || output.get(0).length() != 64)
+ throw new InvalidParameterException("Invalid sha256 of /proc/version");
+ final String moduleName = String.format(MODULE_NAME, output.get(0));
+ HttpURLConnection connection = (HttpURLConnection)new URL(MODULE_LIST_URL).openConnection();
+ connection.setRequestProperty("User-Agent", userAgent);
+ connection.connect();
+ if (connection.getResponseCode() != HttpURLConnection.HTTP_OK)
+ throw new IOException("Hash list could not be found");
+ byte[] input = new byte[1024 * 1024 * 3 /* 3MiB */];
+ int len;
+ try (final InputStream inputStream = connection.getInputStream()) {
+ len = inputStream.read(input);
+ }
+ if (len <= 0)
+ throw new IOException("Hash list was empty");
+ final Map<String, Sha256Digest> modules = verifySignedHashes(new String(input, 0, len, StandardCharsets.UTF_8));
+ if (modules == null)
+ throw new InvalidParameterException("The signature did not verify or invalid hash list format");
+ if (!modules.containsKey(moduleName))
+ return OsConstants.ENOENT;
+ connection = (HttpURLConnection)new URL(String.format(MODULE_URL, moduleName)).openConnection();
+ connection.setRequestProperty("User-Agent", userAgent);
+ connection.connect();
+ if (connection.getResponseCode() != HttpURLConnection.HTTP_OK)
+ throw new IOException("Module file could not be found, despite being on hash list");
+
+ tmpDir.mkdirs();
+ moduleDir.mkdir();
+ File tempFile = null;
+ try {
+ tempFile = File.createTempFile("UNVERIFIED-", null, tmpDir);
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ try (final InputStream inputStream = connection.getInputStream();
+ final FileOutputStream outputStream = new FileOutputStream(tempFile)) {
+ int total = 0;
+ while ((len = inputStream.read(input)) > 0) {
+ total += len;
+ if (total > 1024 * 1024 * 15 /* 15 MiB */)
+ throw new IOException("File too big");
+ outputStream.write(input, 0, len);
+ digest.update(input, 0, len);
+ }
+ outputStream.getFD().sync();
+ }
+ if (!Arrays.equals(digest.digest(), modules.get(moduleName).bytes))
+ throw new IOException("Incorrect file hash");
+
+ if (!tempFile.renameTo(new File(moduleDir, moduleName)))
+ throw new IOException("Unable to rename to final destination");
+ } finally {
+ if (tempFile != null)
+ tempFile.delete();
+ }
+ return OsConstants.EXIT_SUCCESS;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java
new file mode 100644
index 00000000..0ba02184
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedArrayList.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.databinding.ObservableArrayList;
+import androidx.annotation.Nullable;
+
+import com.wireguard.util.Keyed;
+
+import java.util.Collection;
+import java.util.ListIterator;
+import java.util.Objects;
+
+/**
+ * ArrayList that allows looking up elements by some key property. As the key property must always
+ * be retrievable, this list cannot hold {@code null} elements. Because this class places no
+ * restrictions on the order or duplication of keys, lookup by key, as well as all list modification
+ * operations, require O(n) time.
+ */
+
+public class ObservableKeyedArrayList<K, E extends Keyed<? extends K>>
+ extends ObservableArrayList<E> implements ObservableKeyedList<K, E> {
+ @Override
+ public boolean add(@Nullable final E e) {
+ if (e == null)
+ throw new NullPointerException("Trying to add a null element");
+ return super.add(e);
+ }
+
+ @Override
+ public void add(final int index, @Nullable final E e) {
+ if (e == null)
+ throw new NullPointerException("Trying to add a null element");
+ super.add(index, e);
+ }
+
+ @Override
+ public boolean addAll(final Collection<? extends E> c) {
+ if (c.contains(null))
+ throw new NullPointerException("Trying to add a collection with null element(s)");
+ return super.addAll(c);
+ }
+
+ @Override
+ public boolean addAll(final int index, final Collection<? extends E> c) {
+ if (c.contains(null))
+ throw new NullPointerException("Trying to add a collection with null element(s)");
+ return super.addAll(index, c);
+ }
+
+ @Override
+ public boolean containsAllKeys(final Collection<K> keys) {
+ for (final K key : keys)
+ if (!containsKey(key))
+ return false;
+ return true;
+ }
+
+ @Override
+ public boolean containsKey(final K key) {
+ return indexOfKey(key) >= 0;
+ }
+
+ @Nullable
+ @Override
+ public E get(final K key) {
+ final int index = indexOfKey(key);
+ return index >= 0 ? get(index) : null;
+ }
+
+ @Nullable
+ @Override
+ public E getLast(final K key) {
+ final int index = lastIndexOfKey(key);
+ return index >= 0 ? get(index) : null;
+ }
+
+ @Override
+ public int indexOfKey(final K key) {
+ final ListIterator<E> iterator = listIterator();
+ while (iterator.hasNext()) {
+ final int index = iterator.nextIndex();
+ if (Objects.equals(iterator.next().getKey(), key))
+ return index;
+ }
+ return -1;
+ }
+
+ @Override
+ public int lastIndexOfKey(final K key) {
+ final ListIterator<E> iterator = listIterator(size());
+ while (iterator.hasPrevious()) {
+ final int index = iterator.previousIndex();
+ if (Objects.equals(iterator.previous().getKey(), key))
+ return index;
+ }
+ return -1;
+ }
+
+ @Override
+ public E set(final int index, @Nullable final E e) {
+ if (e == null)
+ throw new NullPointerException("Trying to set a null key");
+ return super.set(index, e);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java
new file mode 100644
index 00000000..be8ceb9b
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableKeyedList.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.databinding.ObservableList;
+
+import com.wireguard.util.Keyed;
+import com.wireguard.util.KeyedList;
+
+/**
+ * A list that is both keyed and observable.
+ */
+
+public interface ObservableKeyedList<K, E extends Keyed<? extends K>>
+ extends KeyedList<K, E>, ObservableList<E> {
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java
new file mode 100644
index 00000000..1d585856
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedArrayList.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import androidx.annotation.Nullable;
+
+import com.wireguard.util.Keyed;
+import com.wireguard.util.SortedKeyedList;
+
+import java.util.AbstractList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.Spliterator;
+
+/**
+ * KeyedArrayList that enforces uniqueness and sorted order across the set of keys. This class uses
+ * binary search to improve lookup and replacement times to O(log(n)). However, due to the
+ * array-based nature of this class, insertion and removal of elements with anything but the largest
+ * key still require O(n) time.
+ */
+
+public class ObservableSortedKeyedArrayList<K, E extends Keyed<? extends K>>
+ extends ObservableKeyedArrayList<K, E> implements ObservableSortedKeyedList<K, E> {
+ @Nullable private final Comparator<? super K> comparator;
+ private final transient KeyList<K, E> keyList = new KeyList<>(this);
+
+ @SuppressWarnings("WeakerAccess")
+ public ObservableSortedKeyedArrayList() {
+ comparator = null;
+ }
+
+ public ObservableSortedKeyedArrayList(final Comparator<? super K> comparator) {
+ this.comparator = comparator;
+ }
+
+ public ObservableSortedKeyedArrayList(final Collection<? extends E> c) {
+ this();
+ addAll(c);
+ }
+
+ public ObservableSortedKeyedArrayList(final SortedKeyedList<K, E> other) {
+ this(other.comparator());
+ addAll(other);
+ }
+
+ @Override
+ public boolean add(final E e) {
+ final int insertionPoint = getInsertionPoint(e);
+ if (insertionPoint < 0) {
+ // Skipping insertion is non-destructive if the new and existing objects are the same.
+ if (e == get(-insertionPoint - 1))
+ return false;
+ throw new IllegalArgumentException("Element with same key already exists in list");
+ }
+ super.add(insertionPoint, e);
+ return true;
+ }
+
+ @Override
+ public void add(final int index, final E e) {
+ final int insertionPoint = getInsertionPoint(e);
+ if (insertionPoint < 0)
+ throw new IllegalArgumentException("Element with same key already exists in list");
+ if (insertionPoint != index)
+ throw new IndexOutOfBoundsException("Wrong index given for element");
+ super.add(index, e);
+ }
+
+ @Override
+ public boolean addAll(final Collection<? extends E> c) {
+ boolean didChange = false;
+ for (final E e : c)
+ if (add(e))
+ didChange = true;
+ return didChange;
+ }
+
+ @Override
+ public boolean addAll(int index, final Collection<? extends E> c) {
+ for (final E e : c)
+ add(index++, e);
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public Comparator<? super K> comparator() {
+ return comparator;
+ }
+
+ @Override
+ public K firstKey() {
+ if (isEmpty())
+ // The parameter in the exception is only to shut
+ // lint up, we never care for the exception message.
+ throw new NoSuchElementException("Empty set");
+ return get(0).getKey();
+ }
+
+ private int getInsertionPoint(final E e) {
+ if (comparator != null) {
+ return -Collections.binarySearch(keyList, e.getKey(), comparator) - 1;
+ } else {
+ @SuppressWarnings("unchecked") final List<Comparable<? super K>> list =
+ (List<Comparable<? super K>>) keyList;
+ return -Collections.binarySearch(list, e.getKey()) - 1;
+ }
+ }
+
+ @Override
+ public int indexOfKey(final K key) {
+ final int index;
+ if (comparator != null) {
+ index = Collections.binarySearch(keyList, key, comparator);
+ } else {
+ @SuppressWarnings("unchecked") final List<Comparable<? super K>> list =
+ (List<Comparable<? super K>>) keyList;
+ index = Collections.binarySearch(list, key);
+ }
+ return index >= 0 ? index : -1;
+ }
+
+ @Override
+ public Set<K> keySet() {
+ return keyList;
+ }
+
+ @Override
+ public int lastIndexOfKey(final K key) {
+ // There can never be more than one element with the same key in the list.
+ return indexOfKey(key);
+ }
+
+ @Override
+ public K lastKey() {
+ if (isEmpty())
+ // The parameter in the exception is only to shut
+ // lint up, we never care for the exception message.
+ throw new NoSuchElementException("Empty set");
+ return get(size() - 1).getKey();
+ }
+
+ @Override
+ public E set(final int index, final E e) {
+ final int order;
+ if (comparator != null) {
+ order = comparator.compare(e.getKey(), get(index).getKey());
+ } else {
+ @SuppressWarnings("unchecked") final Comparable<? super K> key =
+ (Comparable<? super K>) e.getKey();
+ order = key.compareTo(get(index).getKey());
+ }
+ if (order != 0) {
+ // Allow replacement if the new key would be inserted adjacent to the replaced element.
+ final int insertionPoint = getInsertionPoint(e);
+ if (insertionPoint < index || insertionPoint > index + 1)
+ throw new IndexOutOfBoundsException("Wrong index given for element");
+ }
+ return super.set(index, e);
+ }
+
+ @Override
+ public Collection<E> values() {
+ return this;
+ }
+
+ private static final class KeyList<K, E extends Keyed<? extends K>>
+ extends AbstractList<K> implements Set<K> {
+ private final ObservableSortedKeyedArrayList<K, E> list;
+
+ private KeyList(final ObservableSortedKeyedArrayList<K, E> list) {
+ this.list = list;
+ }
+
+ @Override
+ public K get(final int index) {
+ return list.get(index).getKey();
+ }
+
+ @Override
+ public int size() {
+ return list.size();
+ }
+
+ @Override
+ @SuppressWarnings("EmptyMethod")
+ public Spliterator<K> spliterator() {
+ return super.spliterator();
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java
new file mode 100644
index 00000000..d796704e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/util/ObservableSortedKeyedList.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.util;
+
+import com.wireguard.util.Keyed;
+import com.wireguard.util.SortedKeyedList;
+
+/**
+ * A list that is both sorted/keyed and observable.
+ */
+
+public interface ObservableSortedKeyedList<K, E extends Keyed<? extends K>>
+ extends ObservableKeyedList<K, E>, SortedKeyedList<K, E> {
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java
new file mode 100644
index 00000000..bcfe14e3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/ConfigProxy.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.viewmodel;
+
+import androidx.databinding.ObservableArrayList;
+import androidx.databinding.ObservableList;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+import com.wireguard.config.Peer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+public class ConfigProxy implements Parcelable {
+ public static final Parcelable.Creator<ConfigProxy> CREATOR = new ConfigProxyCreator();
+
+ private final InterfaceProxy interfaze;
+ private final ObservableList<PeerProxy> peers = new ObservableArrayList<>();
+
+ private ConfigProxy(final Parcel in) {
+ interfaze = in.readParcelable(InterfaceProxy.class.getClassLoader());
+ in.readTypedList(peers, PeerProxy.CREATOR);
+ for (final PeerProxy proxy : peers)
+ proxy.bind(this);
+ }
+
+ public ConfigProxy(final Config other) {
+ interfaze = new InterfaceProxy(other.getInterface());
+ for (final Peer peer : other.getPeers()) {
+ final PeerProxy proxy = new PeerProxy(peer);
+ peers.add(proxy);
+ proxy.bind(this);
+ }
+ }
+
+ public ConfigProxy() {
+ interfaze = new InterfaceProxy();
+ }
+
+ public PeerProxy addPeer() {
+ final PeerProxy proxy = new PeerProxy();
+ peers.add(proxy);
+ proxy.bind(this);
+ return proxy;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public InterfaceProxy getInterface() {
+ return interfaze;
+ }
+
+ public ObservableList<PeerProxy> getPeers() {
+ return peers;
+ }
+
+ public Config resolve() throws BadConfigException {
+ final Collection<Peer> resolvedPeers = new ArrayList<>();
+ for (final PeerProxy proxy : peers)
+ resolvedPeers.add(proxy.resolve());
+ return new Config.Builder()
+ .setInterface(interfaze.resolve())
+ .addPeers(resolvedPeers)
+ .build();
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(interfaze, flags);
+ dest.writeTypedList(peers);
+ }
+
+ private static class ConfigProxyCreator implements Parcelable.Creator<ConfigProxy> {
+ @Override
+ public ConfigProxy createFromParcel(final Parcel in) {
+ return new ConfigProxy(in);
+ }
+
+ @Override
+ public ConfigProxy[] newArray(final int size) {
+ return new ConfigProxy[size];
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java
new file mode 100644
index 00000000..cc9f2dd8
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/InterfaceProxy.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.viewmodel;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.databinding.ObservableArrayList;
+import androidx.databinding.ObservableList;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.wireguard.android.BR;
+import com.wireguard.config.Attribute;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Interface;
+import com.wireguard.crypto.Key;
+import com.wireguard.crypto.KeyFormatException;
+import com.wireguard.crypto.KeyPair;
+
+import java.net.InetAddress;
+import java.util.List;
+
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+public class InterfaceProxy extends BaseObservable implements Parcelable {
+ public static final Parcelable.Creator<InterfaceProxy> CREATOR = new InterfaceProxyCreator();
+
+ private final ObservableList<String> excludedApplications = new ObservableArrayList<>();
+ private String addresses;
+ private String dnsServers;
+ private String listenPort;
+ private String mtu;
+ private String privateKey;
+ private String publicKey;
+
+ private InterfaceProxy(final Parcel in) {
+ addresses = in.readString();
+ dnsServers = in.readString();
+ in.readStringList(excludedApplications);
+ listenPort = in.readString();
+ mtu = in.readString();
+ privateKey = in.readString();
+ publicKey = in.readString();
+ }
+
+ public InterfaceProxy(final Interface other) {
+ addresses = Attribute.join(other.getAddresses());
+ final List<String> dnsServerStrings = StreamSupport.stream(other.getDnsServers())
+ .map(InetAddress::getHostAddress)
+ .collect(Collectors.toUnmodifiableList());
+ dnsServers = Attribute.join(dnsServerStrings);
+ excludedApplications.addAll(other.getExcludedApplications());
+ listenPort = other.getListenPort().map(String::valueOf).orElse("");
+ mtu = other.getMtu().map(String::valueOf).orElse("");
+ final KeyPair keyPair = other.getKeyPair();
+ privateKey = keyPair.getPrivateKey().toBase64();
+ publicKey = keyPair.getPublicKey().toBase64();
+ }
+
+ public InterfaceProxy() {
+ addresses = "";
+ dnsServers = "";
+ listenPort = "";
+ mtu = "";
+ privateKey = "";
+ publicKey = "";
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void generateKeyPair() {
+ final KeyPair keyPair = new KeyPair();
+ privateKey = keyPair.getPrivateKey().toBase64();
+ publicKey = keyPair.getPublicKey().toBase64();
+ notifyPropertyChanged(BR.privateKey);
+ notifyPropertyChanged(BR.publicKey);
+ }
+
+ @Bindable
+ public String getAddresses() {
+ return addresses;
+ }
+
+ @Bindable
+ public String getDnsServers() {
+ return dnsServers;
+ }
+
+ public ObservableList<String> getExcludedApplications() {
+ return excludedApplications;
+ }
+
+ @Bindable
+ public String getListenPort() {
+ return listenPort;
+ }
+
+ @Bindable
+ public String getMtu() {
+ return mtu;
+ }
+
+ @Bindable
+ public String getPrivateKey() {
+ return privateKey;
+ }
+
+ @Bindable
+ public String getPublicKey() {
+ return publicKey;
+ }
+
+ public Interface resolve() throws BadConfigException {
+ final Interface.Builder builder = new Interface.Builder();
+ if (!addresses.isEmpty())
+ builder.parseAddresses(addresses);
+ if (!dnsServers.isEmpty())
+ builder.parseDnsServers(dnsServers);
+ if (!excludedApplications.isEmpty())
+ builder.excludeApplications(excludedApplications);
+ if (!listenPort.isEmpty())
+ builder.parseListenPort(listenPort);
+ if (!mtu.isEmpty())
+ builder.parseMtu(mtu);
+ if (!privateKey.isEmpty())
+ builder.parsePrivateKey(privateKey);
+ return builder.build();
+ }
+
+ public void setAddresses(final String addresses) {
+ this.addresses = addresses;
+ notifyPropertyChanged(BR.addresses);
+ }
+
+ public void setDnsServers(final String dnsServers) {
+ this.dnsServers = dnsServers;
+ notifyPropertyChanged(BR.dnsServers);
+ }
+
+ public void setListenPort(final String listenPort) {
+ this.listenPort = listenPort;
+ notifyPropertyChanged(BR.listenPort);
+ }
+
+ public void setMtu(final String mtu) {
+ this.mtu = mtu;
+ notifyPropertyChanged(BR.mtu);
+ }
+
+ public void setPrivateKey(final String privateKey) {
+ this.privateKey = privateKey;
+ try {
+ publicKey = new KeyPair(Key.fromBase64(privateKey)).getPublicKey().toBase64();
+ } catch (final KeyFormatException ignored) {
+ publicKey = "";
+ }
+ notifyPropertyChanged(BR.privateKey);
+ notifyPropertyChanged(BR.publicKey);
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(addresses);
+ dest.writeString(dnsServers);
+ dest.writeStringList(excludedApplications);
+ dest.writeString(listenPort);
+ dest.writeString(mtu);
+ dest.writeString(privateKey);
+ dest.writeString(publicKey);
+ }
+
+ private static class InterfaceProxyCreator implements Parcelable.Creator<InterfaceProxy> {
+ @Override
+ public InterfaceProxy createFromParcel(final Parcel in) {
+ return new InterfaceProxy(in);
+ }
+
+ @Override
+ public InterfaceProxy[] newArray(final int size) {
+ return new InterfaceProxy[size];
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java
new file mode 100644
index 00000000..7dc50f09
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/viewmodel/PeerProxy.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.viewmodel;
+
+import androidx.databinding.BaseObservable;
+import androidx.databinding.Bindable;
+import androidx.databinding.Observable;
+import androidx.databinding.ObservableList;
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.Nullable;
+
+import com.wireguard.android.BR;
+import com.wireguard.config.Attribute;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.InetEndpoint;
+import com.wireguard.config.Peer;
+import com.wireguard.crypto.Key;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import java9.util.Lists;
+import java9.util.Sets;
+import java9.util.stream.Collectors;
+import java9.util.stream.Stream;
+
+public class PeerProxy extends BaseObservable implements Parcelable {
+ public static final Parcelable.Creator<PeerProxy> CREATOR = new PeerProxyCreator();
+ private static final Set<String> IPV4_PUBLIC_NETWORKS = new LinkedHashSet<>(Lists.of(
+ "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
+ "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
+ "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
+ "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
+ "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
+ "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
+ ));
+ private static final Set<String> IPV4_WILDCARD = Sets.of("0.0.0.0/0");
+
+ private final List<String> dnsRoutes = new ArrayList<>();
+ private String allowedIps;
+ private AllowedIpsState allowedIpsState = AllowedIpsState.INVALID;
+ private String endpoint;
+ @Nullable private InterfaceDnsListener interfaceDnsListener;
+ @Nullable private ConfigProxy owner;
+ @Nullable private PeerListListener peerListListener;
+ private String persistentKeepalive;
+ private String preSharedKey;
+ private String publicKey;
+ private int totalPeers;
+
+ private PeerProxy(final Parcel in) {
+ allowedIps = in.readString();
+ endpoint = in.readString();
+ persistentKeepalive = in.readString();
+ preSharedKey = in.readString();
+ publicKey = in.readString();
+ }
+
+ public PeerProxy(final Peer other) {
+ allowedIps = Attribute.join(other.getAllowedIps());
+ endpoint = other.getEndpoint().map(InetEndpoint::toString).orElse("");
+ persistentKeepalive = other.getPersistentKeepalive().map(String::valueOf).orElse("");
+ preSharedKey = other.getPreSharedKey().map(Key::toBase64).orElse("");
+ publicKey = other.getPublicKey().toBase64();
+ }
+
+ public PeerProxy() {
+ allowedIps = "";
+ endpoint = "";
+ persistentKeepalive = "";
+ preSharedKey = "";
+ publicKey = "";
+ }
+
+ public void bind(final ConfigProxy owner) {
+ final InterfaceProxy interfaze = owner.getInterface();
+ final ObservableList<PeerProxy> peers = owner.getPeers();
+ if (interfaceDnsListener == null)
+ interfaceDnsListener = new InterfaceDnsListener(this);
+ interfaze.addOnPropertyChangedCallback(interfaceDnsListener);
+ setInterfaceDns(interfaze.getDnsServers());
+ if (peerListListener == null)
+ peerListListener = new PeerListListener(this);
+ peers.addOnListChangedCallback(peerListListener);
+ setTotalPeers(peers.size());
+ this.owner = owner;
+ }
+
+ private void calculateAllowedIpsState() {
+ final AllowedIpsState newState;
+ if (totalPeers == 1) {
+ // String comparison works because we only care if allowedIps is a superset of one of
+ // the above sets of (valid) *networks*. We are not checking for a superset based on
+ // the individual addresses in each set.
+ final Collection<String> networkStrings = getAllowedIpsSet();
+ // If allowedIps contains both the wildcard and the public networks, then private
+ // networks aren't excluded!
+ if (networkStrings.containsAll(IPV4_WILDCARD))
+ newState = AllowedIpsState.CONTAINS_IPV4_WILDCARD;
+ else if (networkStrings.containsAll(IPV4_PUBLIC_NETWORKS))
+ newState = AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS;
+ else
+ newState = AllowedIpsState.OTHER;
+ } else {
+ newState = AllowedIpsState.INVALID;
+ }
+ if (newState != allowedIpsState) {
+ allowedIpsState = newState;
+ notifyPropertyChanged(BR.ableToExcludePrivateIps);
+ notifyPropertyChanged(BR.excludingPrivateIps);
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Bindable
+ public String getAllowedIps() {
+ return allowedIps;
+ }
+
+ private Set<String> getAllowedIpsSet() {
+ return new LinkedHashSet<>(Lists.of(Attribute.split(allowedIps)));
+ }
+
+ @Bindable
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ @Bindable
+ public String getPersistentKeepalive() {
+ return persistentKeepalive;
+ }
+
+ @Bindable
+ public String getPreSharedKey() {
+ return preSharedKey;
+ }
+
+ @Bindable
+ public String getPublicKey() {
+ return publicKey;
+ }
+
+ @Bindable
+ public boolean isAbleToExcludePrivateIps() {
+ return allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS
+ || allowedIpsState == AllowedIpsState.CONTAINS_IPV4_WILDCARD;
+ }
+
+ @Bindable
+ public boolean isExcludingPrivateIps() {
+ return allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS;
+ }
+
+ public Peer resolve() throws BadConfigException {
+ final Peer.Builder builder = new Peer.Builder();
+ if (!allowedIps.isEmpty())
+ builder.parseAllowedIPs(allowedIps);
+ if (!endpoint.isEmpty())
+ builder.parseEndpoint(endpoint);
+ if (!persistentKeepalive.isEmpty())
+ builder.parsePersistentKeepalive(persistentKeepalive);
+ if (!preSharedKey.isEmpty())
+ builder.parsePreSharedKey(preSharedKey);
+ if (!publicKey.isEmpty())
+ builder.parsePublicKey(publicKey);
+ return builder.build();
+ }
+
+ public void setAllowedIps(final String allowedIps) {
+ this.allowedIps = allowedIps;
+ notifyPropertyChanged(BR.allowedIps);
+ calculateAllowedIpsState();
+ }
+
+ public void setEndpoint(final String endpoint) {
+ this.endpoint = endpoint;
+ notifyPropertyChanged(BR.endpoint);
+ }
+
+ public void setExcludingPrivateIps(final boolean excludingPrivateIps) {
+ if (!isAbleToExcludePrivateIps() || isExcludingPrivateIps() == excludingPrivateIps)
+ return;
+ final Set<String> oldNetworks = excludingPrivateIps ? IPV4_WILDCARD : IPV4_PUBLIC_NETWORKS;
+ final Set<String> newNetworks = excludingPrivateIps ? IPV4_PUBLIC_NETWORKS : IPV4_WILDCARD;
+ final Collection<String> input = getAllowedIpsSet();
+ final int outputSize = input.size() - oldNetworks.size() + newNetworks.size();
+ final Collection<String> output = new LinkedHashSet<>(outputSize);
+ boolean replaced = false;
+ // Replace the first instance of the wildcard with the public network list, or vice versa.
+ for (final String network : input) {
+ if (oldNetworks.contains(network)) {
+ if (!replaced) {
+ for (final String replacement : newNetworks)
+ if (!output.contains(replacement))
+ output.add(replacement);
+ replaced = true;
+ }
+ } else if (!output.contains(network)) {
+ output.add(network);
+ }
+ }
+ // DNS servers only need to handled specially when we're excluding private IPs.
+ if (excludingPrivateIps)
+ output.addAll(dnsRoutes);
+ else
+ output.removeAll(dnsRoutes);
+ allowedIps = Attribute.join(output);
+ allowedIpsState = excludingPrivateIps ?
+ AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS : AllowedIpsState.CONTAINS_IPV4_WILDCARD;
+ notifyPropertyChanged(BR.allowedIps);
+ notifyPropertyChanged(BR.excludingPrivateIps);
+ }
+
+ private void setInterfaceDns(final CharSequence dnsServers) {
+ final List<String> newDnsRoutes = Stream.of(Attribute.split(dnsServers))
+ .filter(server -> !server.contains(":"))
+ .map(server -> server + "/32")
+ .collect(Collectors.toUnmodifiableList());
+ if (allowedIpsState == AllowedIpsState.CONTAINS_IPV4_PUBLIC_NETWORKS) {
+ final Collection<String> input = getAllowedIpsSet();
+ final Collection<String> output = new LinkedHashSet<>(input.size() + 1);
+ // Yes, this is quadratic in the number of DNS servers, but most users have 1 or 2.
+ for (final String network : input)
+ if (!dnsRoutes.contains(network) || newDnsRoutes.contains(network))
+ output.add(network);
+ // Since output is a Set, this does the Right Thing™ (it does not duplicate networks).
+ output.addAll(newDnsRoutes);
+ // None of the public networks are /32s, so this cannot change the AllowedIPs state.
+ allowedIps = Attribute.join(output);
+ notifyPropertyChanged(BR.allowedIps);
+ }
+ dnsRoutes.clear();
+ dnsRoutes.addAll(newDnsRoutes);
+ }
+
+ public void setPersistentKeepalive(final String persistentKeepalive) {
+ this.persistentKeepalive = persistentKeepalive;
+ notifyPropertyChanged(BR.persistentKeepalive);
+ }
+
+ public void setPreSharedKey(final String preSharedKey) {
+ this.preSharedKey = preSharedKey;
+ notifyPropertyChanged(BR.preSharedKey);
+ }
+
+ public void setPublicKey(final String publicKey) {
+ this.publicKey = publicKey;
+ notifyPropertyChanged(BR.publicKey);
+ }
+
+ private void setTotalPeers(final int totalPeers) {
+ if (this.totalPeers == totalPeers)
+ return;
+ this.totalPeers = totalPeers;
+ calculateAllowedIpsState();
+ }
+
+ public void unbind() {
+ if (owner == null)
+ return;
+ final InterfaceProxy interfaze = owner.getInterface();
+ final ObservableList<PeerProxy> peers = owner.getPeers();
+ if (interfaceDnsListener != null)
+ interfaze.removeOnPropertyChangedCallback(interfaceDnsListener);
+ if (peerListListener != null)
+ peers.removeOnListChangedCallback(peerListListener);
+ peers.remove(this);
+ setInterfaceDns("");
+ setTotalPeers(0);
+ owner = null;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(allowedIps);
+ dest.writeString(endpoint);
+ dest.writeString(persistentKeepalive);
+ dest.writeString(preSharedKey);
+ dest.writeString(publicKey);
+ }
+
+ private enum AllowedIpsState {
+ CONTAINS_IPV4_PUBLIC_NETWORKS,
+ CONTAINS_IPV4_WILDCARD,
+ INVALID,
+ OTHER
+ }
+
+ private static final class InterfaceDnsListener extends Observable.OnPropertyChangedCallback {
+ private final WeakReference<PeerProxy> weakPeerProxy;
+
+ private InterfaceDnsListener(final PeerProxy peerProxy) {
+ weakPeerProxy = new WeakReference<>(peerProxy);
+ }
+
+ @Override
+ public void onPropertyChanged(final Observable sender, final int propertyId) {
+ @Nullable final PeerProxy peerProxy = weakPeerProxy.get();
+ if (peerProxy == null) {
+ sender.removeOnPropertyChangedCallback(this);
+ return;
+ }
+ // This shouldn't be possible, but try to avoid a ClassCastException anyway.
+ if (!(sender instanceof InterfaceProxy))
+ return;
+ if (!(propertyId == BR._all || propertyId == BR.dnsServers))
+ return;
+ peerProxy.setInterfaceDns(((InterfaceProxy) sender).getDnsServers());
+ }
+ }
+
+ private static final class PeerListListener
+ extends ObservableList.OnListChangedCallback<ObservableList<PeerProxy>> {
+ private final WeakReference<PeerProxy> weakPeerProxy;
+
+ private PeerListListener(final PeerProxy peerProxy) {
+ weakPeerProxy = new WeakReference<>(peerProxy);
+ }
+
+ @Override
+ public void onChanged(final ObservableList<PeerProxy> sender) {
+ @Nullable final PeerProxy peerProxy = weakPeerProxy.get();
+ if (peerProxy == null) {
+ sender.removeOnListChangedCallback(this);
+ return;
+ }
+ peerProxy.setTotalPeers(sender.size());
+ }
+
+ @Override
+ public void onItemRangeChanged(final ObservableList<PeerProxy> sender,
+ final int positionStart, final int itemCount) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onItemRangeInserted(final ObservableList<PeerProxy> sender,
+ final int positionStart, final int itemCount) {
+ onChanged(sender);
+ }
+
+ @Override
+ public void onItemRangeMoved(final ObservableList<PeerProxy> sender,
+ final int fromPosition, final int toPosition,
+ final int itemCount) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onItemRangeRemoved(final ObservableList<PeerProxy> sender,
+ final int positionStart, final int itemCount) {
+ onChanged(sender);
+ }
+ }
+
+ private static class PeerProxyCreator implements Parcelable.Creator<PeerProxy> {
+ @Override
+ public PeerProxy createFromParcel(final Parcel in) {
+ return new PeerProxy(in);
+ }
+
+ @Override
+ public PeerProxy[] newArray(final int size) {
+ return new PeerProxy[size];
+ }
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java
new file mode 100644
index 00000000..79572aa3
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/KeyInputFilter.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import androidx.annotation.Nullable;
+import android.text.InputFilter;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+
+import com.wireguard.crypto.Key;
+
+/**
+ * InputFilter for entering WireGuard private/public keys encoded with base64.
+ */
+
+public class KeyInputFilter implements InputFilter {
+ private static boolean isAllowed(final char c) {
+ return Character.isLetterOrDigit(c) || c == '+' || c == '/';
+ }
+
+ public static InputFilter newInstance() {
+ return new KeyInputFilter();
+ }
+
+ @Nullable
+ @Override
+ public CharSequence filter(final CharSequence source,
+ final int sStart, final int sEnd,
+ final Spanned dest,
+ final int dStart, final int dEnd) {
+ SpannableStringBuilder replacement = null;
+ int rIndex = 0;
+ final int dLength = dest.length();
+ for (int sIndex = sStart; sIndex < sEnd; ++sIndex) {
+ final char c = source.charAt(sIndex);
+ final int dIndex = dStart + (sIndex - sStart);
+ // Restrict characters to the base64 character set.
+ // Ensure adding this character does not push the length over the limit.
+ if (((dIndex + 1 < Key.Format.BASE64.getLength() && isAllowed(c)) ||
+ (dIndex + 1 == Key.Format.BASE64.getLength() && c == '=')) &&
+ dLength + (sIndex - sStart) < Key.Format.BASE64.getLength()) {
+ ++rIndex;
+ } else {
+ if (replacement == null)
+ replacement = new SpannableStringBuilder(source, sStart, sEnd);
+ replacement.delete(rIndex, rIndex + 1);
+ }
+ }
+ return replacement;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java
new file mode 100644
index 00000000..2fe9c924
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/MultiselectableRelativeLayout.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.RelativeLayout;
+
+import com.wireguard.android.R;
+
+public class MultiselectableRelativeLayout extends RelativeLayout {
+ private static final int[] STATE_MULTISELECTED = {R.attr.state_multiselected};
+ private boolean multiselected;
+
+ public MultiselectableRelativeLayout(final Context context) {
+ super(context);
+ }
+
+ public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs, final int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public MultiselectableRelativeLayout(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ @Override
+ protected int[] onCreateDrawableState(final int extraSpace) {
+ if (multiselected) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+ mergeDrawableStates(drawableState, STATE_MULTISELECTED);
+ return drawableState;
+ }
+ return super.onCreateDrawableState(extraSpace);
+ }
+
+ public void setMultiSelected(final boolean on) {
+ if (!multiselected) {
+ multiselected = true;
+ refreshDrawableState();
+ }
+ setActivated(on);
+ }
+
+ public void setSingleSelected(final boolean on) {
+ if (multiselected) {
+ multiselected = false;
+ refreshDrawableState();
+ }
+ setActivated(on);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java
new file mode 100644
index 00000000..030be25a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/NameInputFilter.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import androidx.annotation.Nullable;
+import android.text.InputFilter;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+
+import com.wireguard.android.backend.Tunnel;
+
+/**
+ * InputFilter for entering WireGuard configuration names (Linux interface names).
+ */
+
+public class NameInputFilter implements InputFilter {
+ private static boolean isAllowed(final char c) {
+ return Character.isLetterOrDigit(c) || "_=+.-".indexOf(c) >= 0;
+ }
+
+ public static InputFilter newInstance() {
+ return new NameInputFilter();
+ }
+
+ @Nullable
+ @Override
+ public CharSequence filter(final CharSequence source,
+ final int sStart, final int sEnd,
+ final Spanned dest,
+ final int dStart, final int dEnd) {
+ SpannableStringBuilder replacement = null;
+ int rIndex = 0;
+ final int dLength = dest.length();
+ for (int sIndex = sStart; sIndex < sEnd; ++sIndex) {
+ final char c = source.charAt(sIndex);
+ final int dIndex = dStart + (sIndex - sStart);
+ // Restrict characters to those valid in interfaces.
+ // Ensure adding this character does not push the length over the limit.
+ if ((dIndex < Tunnel.NAME_MAX_LENGTH && isAllowed(c)) &&
+ dLength + (sIndex - sStart) < Tunnel.NAME_MAX_LENGTH) {
+ ++rIndex;
+ } else {
+ if (replacement == null)
+ replacement = new SpannableStringBuilder(source, sStart, sEnd);
+ replacement.delete(rIndex, rIndex + 1);
+ }
+ }
+ return replacement;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java
new file mode 100644
index 00000000..e020aa81
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/SlashDrawable.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright © 2018 The Android Open Source Project
+ * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.content.res.ColorStateList;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Path.Direction;
+import android.graphics.PixelFormat;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import androidx.annotation.ColorInt;
+import androidx.annotation.IntRange;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import android.util.FloatProperty;
+
+@RequiresApi(Build.VERSION_CODES.N)
+public class SlashDrawable extends Drawable {
+
+ private static final float CENTER_X = 10.65f;
+ private static final float CENTER_Y = 11.869239f;
+ private static final float CORNER_RADIUS = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? 0f : 1f;
+ // Draw the slash washington-monument style; rotate to no-u-turn style
+ private static final float DEFAULT_ROTATION = -45f;
+ private static final long QS_ANIM_LENGTH = 350;
+ private static final float SCALE = 24f;
+ private static final float SLASH_HEIGHT = 28f;
+ // These values are derived in un-rotated (vertical) orientation
+ private static final float SLASH_WIDTH = 1.8384776f;
+ // Bottom is derived during animation
+ private static final float LEFT = (CENTER_X - (SLASH_WIDTH / 2)) / SCALE;
+ private static final float RIGHT = (CENTER_X + (SLASH_WIDTH / 2)) / SCALE;
+ private static final float TOP = (CENTER_Y - (SLASH_HEIGHT / 2)) / SCALE;
+ private static final FloatProperty mSlashLengthProp = new FloatProperty<SlashDrawable>("slashLength") {
+ @Override
+ public Float get(final SlashDrawable object) {
+ return object.mCurrentSlashLength;
+ }
+
+ @Override
+ public void setValue(final SlashDrawable object, final float value) {
+ object.mCurrentSlashLength = value;
+ }
+ };
+ private final Drawable mDrawable;
+ private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path mPath = new Path();
+ private final RectF mSlashRect = new RectF(0, 0, 0, 0);
+ private boolean mAnimationEnabled = true;
+ // Animate this value on change
+ private float mCurrentSlashLength;
+ private float mRotation;
+ private boolean mSlashed;
+
+ public SlashDrawable(final Drawable d) {
+ mDrawable = d;
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public void draw(final Canvas canvas) {
+ canvas.save();
+ final Matrix m = new Matrix();
+ final int width = getBounds().width();
+ final int height = getBounds().height();
+ final float radiusX = scale(CORNER_RADIUS, width);
+ final float radiusY = scale(CORNER_RADIUS, height);
+ updateRect(
+ scale(LEFT, width),
+ scale(TOP, height),
+ scale(RIGHT, width),
+ scale(TOP + mCurrentSlashLength, height)
+ );
+
+ mPath.reset();
+ // Draw the slash vertically
+ mPath.addRoundRect(mSlashRect, radiusX, radiusY, Direction.CW);
+ // Rotate -45 + desired rotation
+ m.setRotate(mRotation + DEFAULT_ROTATION, width / 2, height / 2);
+ mPath.transform(m);
+ canvas.drawPath(mPath, mPaint);
+
+ // Rotate back to vertical
+ m.setRotate(-mRotation - DEFAULT_ROTATION, width / 2, height / 2);
+ mPath.transform(m);
+
+ // Draw another rect right next to the first, for clipping
+ m.setTranslate(mSlashRect.width(), 0);
+ mPath.transform(m);
+ mPath.addRoundRect(mSlashRect, 1.0f * width, 1.0f * height, Direction.CW);
+ m.setRotate(mRotation + DEFAULT_ROTATION, width / 2, height / 2);
+ mPath.transform(m);
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ canvas.clipPath(mPath, Region.Op.DIFFERENCE);
+ else
+ canvas.clipOutPath(mPath);
+
+ mDrawable.draw(canvas);
+ canvas.restore();
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return mDrawable.getIntrinsicHeight();
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return mDrawable.getIntrinsicWidth();
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public int getOpacity() {
+ return PixelFormat.OPAQUE;
+ }
+
+ @Override
+ protected void onBoundsChange(final Rect bounds) {
+ super.onBoundsChange(bounds);
+ mDrawable.setBounds(bounds);
+ }
+
+ private float scale(final float frac, final int width) {
+ return frac * width;
+ }
+
+ @Override
+ public void setAlpha(@IntRange(from = 0, to = 255) final int alpha) {
+ mDrawable.setAlpha(alpha);
+ mPaint.setAlpha(alpha);
+ }
+
+ public void setAnimationEnabled(final boolean enabled) {
+ mAnimationEnabled = enabled;
+ }
+
+ @Override
+ public void setColorFilter(@Nullable final ColorFilter colorFilter) {
+ mDrawable.setColorFilter(colorFilter);
+ mPaint.setColorFilter(colorFilter);
+ }
+
+ private void setDrawableTintList(@Nullable final ColorStateList tint) {
+ mDrawable.setTintList(tint);
+ }
+
+ public void setRotation(final float rotation) {
+ if (mRotation == rotation)
+ return;
+ mRotation = rotation;
+ invalidateSelf();
+ }
+
+ @SuppressWarnings("unchecked")
+ public void setSlashed(final boolean slashed) {
+ if (mSlashed == slashed) return;
+
+ mSlashed = slashed;
+
+ final float end = mSlashed ? SLASH_HEIGHT / SCALE : 0f;
+ final float start = mSlashed ? 0f : SLASH_HEIGHT / SCALE;
+
+ if (mAnimationEnabled) {
+ final ObjectAnimator anim = ObjectAnimator.ofFloat(this, mSlashLengthProp, start, end);
+ anim.addUpdateListener((ValueAnimator valueAnimator) -> invalidateSelf());
+ anim.setDuration(QS_ANIM_LENGTH);
+ anim.start();
+ } else {
+ mCurrentSlashLength = end;
+ invalidateSelf();
+ }
+ }
+
+ @Override
+ public void setTint(@ColorInt final int tintColor) {
+ super.setTint(tintColor);
+ mDrawable.setTint(tintColor);
+ mPaint.setColor(tintColor);
+ }
+
+ @Override
+ public void setTintList(@Nullable final ColorStateList tint) {
+ super.setTintList(tint);
+ setDrawableTintList(tint);
+ mPaint.setColor(tint == null ? 0 : tint.getDefaultColor());
+ invalidateSelf();
+ }
+
+ @Override
+ public void setTintMode(final Mode tintMode) {
+ super.setTintMode(tintMode);
+ mDrawable.setTintMode(tintMode);
+ }
+
+ private void updateRect(final float left, final float top, final float right, final float bottom) {
+ mSlashRect.left = left;
+ mSlashRect.top = top;
+ mSlashRect.right = right;
+ mSlashRect.bottom = bottom;
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
new file mode 100644
index 00000000..dcb9aceb
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright © 2013 The Android Open Source Project
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.widget;
+
+import android.content.Context;
+import android.os.Parcelable;
+import androidx.annotation.Nullable;
+import android.util.AttributeSet;
+import android.widget.Switch;
+
+public class ToggleSwitch extends Switch {
+ private boolean isRestoringState;
+ @Nullable private OnBeforeCheckedChangeListener listener;
+
+ public ToggleSwitch(final Context context) {
+ this(context, null);
+ }
+
+ @SuppressWarnings({"SameParameterValue", "WeakerAccess"})
+ public ToggleSwitch(final Context context, @Nullable final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void onRestoreInstanceState(final Parcelable state) {
+ isRestoringState = true;
+ super.onRestoreInstanceState(state);
+ isRestoringState = false;
+ }
+
+ @Override
+ public void setChecked(final boolean checked) {
+ if (checked == isChecked())
+ return;
+ if (isRestoringState || listener == null) {
+ super.setChecked(checked);
+ return;
+ }
+ setEnabled(false);
+ listener.onBeforeCheckedChanged(this, checked);
+ }
+
+ public void setCheckedInternal(final boolean checked) {
+ super.setChecked(checked);
+ setEnabled(true);
+ }
+
+ public void setOnBeforeCheckedChangeListener(final OnBeforeCheckedChangeListener listener) {
+ this.listener = listener;
+ }
+
+ public interface OnBeforeCheckedChangeListener {
+ void onBeforeCheckedChanged(ToggleSwitch toggleSwitch, boolean checked);
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/util/Keyed.java b/ui/src/main/java/com/wireguard/util/Keyed.java
new file mode 100644
index 00000000..f31a43a2
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/util/Keyed.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+/**
+ * Interface for objects that have a identifying key of the given type.
+ */
+
+public interface Keyed<K> {
+ K getKey();
+}
diff --git a/ui/src/main/java/com/wireguard/util/KeyedList.java b/ui/src/main/java/com/wireguard/util/KeyedList.java
new file mode 100644
index 00000000..c116c1da
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/util/KeyedList.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A list containing elements that can be looked up by key. A {@code KeyedList} cannot contain
+ * {@code null} elements.
+ */
+
+public interface KeyedList<K, E extends Keyed<? extends K>> extends List<E> {
+ boolean containsAllKeys(Collection<K> keys);
+
+ boolean containsKey(K key);
+
+ @Nullable
+ E get(K key);
+
+ @Nullable
+ E getLast(K key);
+
+ int indexOfKey(K key);
+
+ int lastIndexOfKey(K key);
+}
diff --git a/ui/src/main/java/com/wireguard/util/SortedKeyedList.java b/ui/src/main/java/com/wireguard/util/SortedKeyedList.java
new file mode 100644
index 00000000..b144fc85
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/util/SortedKeyedList.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.util;
+
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Set;
+
+/**
+ * A keyed list where all elements are sorted by the comparator returned by {@code comparator()}
+ * applied to their keys.
+ */
+
+public interface SortedKeyedList<K, E extends Keyed<? extends K>> extends KeyedList<K, E> {
+ Comparator<? super K> comparator();
+
+ @Nullable
+ K firstKey();
+
+ Set<K> keySet();
+
+ @Nullable
+ K lastKey();
+
+ Collection<E> values();
+}