summaryrefslogtreecommitdiffhomepage
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle14
-rw-r--r--app/src/main/AndroidManifest.xml35
-rw-r--r--app/src/main/java/com/wireguard/android/AddActivity.java46
-rw-r--r--app/src/main/java/com/wireguard/android/Application.java124
-rw-r--r--app/src/main/java/com/wireguard/android/BaseConfigActivity.java103
-rw-r--r--app/src/main/java/com/wireguard/android/BaseConfigFragment.java50
-rw-r--r--app/src/main/java/com/wireguard/android/BootCompletedReceiver.java17
-rw-r--r--app/src/main/java/com/wireguard/android/BootShutdownReceiver.java28
-rw-r--r--app/src/main/java/com/wireguard/android/ConfigActivity.java289
-rw-r--r--app/src/main/java/com/wireguard/android/ConfigDetailFragment.java44
-rw-r--r--app/src/main/java/com/wireguard/android/ConfigEditFragment.java139
-rw-r--r--app/src/main/java/com/wireguard/android/ConfigListFragment.java198
-rw-r--r--app/src/main/java/com/wireguard/android/NotSupportedActivity.java28
-rw-r--r--app/src/main/java/com/wireguard/android/QuickTileService.java164
-rw-r--r--app/src/main/java/com/wireguard/android/activity/BaseActivity.java94
-rw-r--r--app/src/main/java/com/wireguard/android/activity/MainActivity.java146
-rw-r--r--app/src/main/java/com/wireguard/android/activity/SettingsActivity.java (renamed from app/src/main/java/com/wireguard/android/SettingsActivity.java)82
-rw-r--r--app/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java28
-rw-r--r--app/src/main/java/com/wireguard/android/backend/Backend.java57
-rw-r--r--app/src/main/java/com/wireguard/android/backend/WgQuickBackend.java94
-rw-r--r--app/src/main/java/com/wireguard/android/backends/VpnService.java559
-rw-r--r--app/src/main/java/com/wireguard/android/configStore/ConfigStore.java65
-rw-r--r--app/src/main/java/com/wireguard/android/configStore/FileConfigStore.java98
-rw-r--r--app/src/main/java/com/wireguard/android/databinding/BindingAdapters.java34
-rw-r--r--app/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java3
-rw-r--r--app/src/main/java/com/wireguard/android/databinding/ObservableListAdapter.java6
-rw-r--r--app/src/main/java/com/wireguard/android/databinding/ObservableMapAdapter.java27
-rw-r--r--app/src/main/java/com/wireguard/android/databinding/ObservableNavigableMap.java (renamed from app/src/main/java/com/wireguard/android/databinding/ObservableSortedMap.java)4
-rw-r--r--app/src/main/java/com/wireguard/android/databinding/ObservableTreeMap.java17
-rw-r--r--app/src/main/java/com/wireguard/android/fragment/BaseFragment.java45
-rw-r--r--app/src/main/java/com/wireguard/android/fragment/ConfigEditorFragment.java205
-rw-r--r--app/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java60
-rw-r--r--app/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java270
-rw-r--r--app/src/main/java/com/wireguard/android/model/Tunnel.java166
-rw-r--r--app/src/main/java/com/wireguard/android/model/TunnelCollection.java10
-rw-r--r--app/src/main/java/com/wireguard/android/model/TunnelManager.java111
-rw-r--r--app/src/main/java/com/wireguard/android/preference/TunnelListPreference.java (renamed from app/src/main/java/com/wireguard/android/ConfigListPreference.java)20
-rw-r--r--app/src/main/java/com/wireguard/android/util/AsyncWorker.java65
-rw-r--r--app/src/main/java/com/wireguard/android/util/ClipboardUtils.java32
-rw-r--r--app/src/main/java/com/wireguard/android/util/ExceptionLoggers.java38
-rw-r--r--app/src/main/java/com/wireguard/android/util/RootShell.java (renamed from app/src/main/java/com/wireguard/android/backends/RootShell.java)26
-rw-r--r--app/src/main/java/com/wireguard/android/widget/KeyInputFilter.java (renamed from app/src/main/java/com/wireguard/android/KeyInputFilter.java)2
-rw-r--r--app/src/main/java/com/wireguard/android/widget/NameInputFilter.java (renamed from app/src/main/java/com/wireguard/android/NameInputFilter.java)8
-rw-r--r--app/src/main/java/com/wireguard/android/widget/ToggleSwitch.java36
-rw-r--r--app/src/main/java/com/wireguard/config/Attribute.java30
-rw-r--r--app/src/main/java/com/wireguard/config/Config.java138
-rw-r--r--app/src/main/java/com/wireguard/config/Copyable.java10
-rw-r--r--app/src/main/java/com/wireguard/config/Interface.java46
-rw-r--r--app/src/main/java/com/wireguard/config/Peer.java49
-rw-r--r--app/src/main/res/layout-w720dp/config_activity.xml28
-rw-r--r--app/src/main/res/layout/config_activity.xml7
-rw-r--r--app/src/main/res/layout/config_detail_fragment.xml86
-rw-r--r--app/src/main/res/layout/config_edit_fragment.xml219
-rw-r--r--app/src/main/res/layout/config_editor_fragment.xml233
-rw-r--r--app/src/main/res/layout/config_editor_peer.xml (renamed from app/src/main/res/layout/config_edit_peer.xml)8
-rw-r--r--app/src/main/res/layout/config_list_fragment.xml54
-rw-r--r--app/src/main/res/layout/main_activity.xml (renamed from app/src/main/res/layout/add_activity.xml)4
-rw-r--r--app/src/main/res/layout/not_supported_activity.xml15
-rw-r--r--app/src/main/res/layout/tunnel_detail_fragment.xml142
-rw-r--r--app/src/main/res/layout/tunnel_detail_peer.xml (renamed from app/src/main/res/layout/config_detail_peer.xml)8
-rw-r--r--app/src/main/res/layout/tunnel_list_fragment.xml59
-rw-r--r--app/src/main/res/layout/tunnel_list_item.xml (renamed from app/src/main/res/layout/config_list_item.xml)25
-rw-r--r--app/src/main/res/menu/config_editor.xml (renamed from app/src/main/res/menu/config_edit.xml)0
-rw-r--r--app/src/main/res/menu/main_activity.xml (renamed from app/src/main/res/menu/main.xml)0
-rw-r--r--app/src/main/res/menu/tunnel_detail.xml (renamed from app/src/main/res/menu/config_detail.xml)0
-rw-r--r--app/src/main/res/menu/tunnel_list_action_mode.xml (renamed from app/src/main/res/menu/config_list_delete.xml)0
-rw-r--r--app/src/main/res/values/strings.xml8
-rw-r--r--app/src/main/res/xml/preferences.xml2
68 files changed, 2563 insertions, 2295 deletions
diff --git a/app/build.gradle b/app/build.gradle
index d022c16b..4620cfcd 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -31,6 +31,20 @@ android {
}
dependencies {
+ annotationProcessor 'com.google.dagger:dagger-compiler:2.14.1'
+ implementation 'com.android.databinding:library:1.3.3'
+ implementation 'com.android.support:support-annotations:27.0.2'
+ implementation 'com.commonsware.cwac:crossport:0.2.1'
+ implementation 'com.gabrielittner.threetenbp:lazythreetenbp:0.2.0'
implementation 'com.getbase:floatingactionbutton:1.10.1'
+ implementation 'com.google.dagger:dagger:2.14.1'
+ implementation 'net.sourceforge.streamsupport:android-retrofuture:1.6.0'
+ implementation 'net.sourceforge.streamsupport:android-retrostreams:1.6.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
}
+
+repositories {
+ maven {
+ url "https://s3.amazonaws.com/repo.commonsware.com"
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 107f6a5f..0b44214e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,25 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
package="com.wireguard.android"
android:installLocation="internalOnly">
+ <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
- android:extractNativeLibs="true"
+ android:name=".Application"
android:allowBackup="false"
+ android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
- android:theme="@android:style/Theme.Material.Light.DarkActionBar">
+ android:theme="@android:style/Theme.Material.Light.DarkActionBar"
+ tools:ignore="UnusedAttribute">
- <activity
- android:name=".AddActivity"
- android:label="@string/add_activity_title"
- android:parentActivityName=".ConfigActivity" />
+ <activity android:name=".activity.MainActivity">
- <activity android:name=".ConfigActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -32,17 +32,17 @@
</activity>
<activity
- android:name=".NotSupportedActivity"
- android:label="@string/not_supported"
- android:parentActivityName=".ConfigActivity" />
+ android:name=".activity.SettingsActivity"
+ android:label="@string/settings" />
<activity
- android:name=".SettingsActivity"
- android:label="@string/settings"
- android:parentActivityName=".ConfigActivity" />
+ android:name=".activity.TunnelCreatorActivity"
+ android:label="@string/add_activity_title" />
+
+ <receiver android:name=".BootShutdownReceiver">
- <receiver android:name=".BootCompletedReceiver">
<intent-filter>
+ <action android:name="android.intent.action.ACTION_SHUTDOWN" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
@@ -51,17 +51,14 @@
android:name=".QuickTileService"
android:icon="@drawable/ic_tile"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
+
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
- android:value="true" />
+ android:value="false" />
</service>
- <service
- android:name=".backends.VpnService"
- android:exported="false" />
</application>
-
</manifest>
diff --git a/app/src/main/java/com/wireguard/android/AddActivity.java b/app/src/main/java/com/wireguard/android/AddActivity.java
deleted file mode 100644
index 5b57e634..00000000
--- a/app/src/main/java/com/wireguard/android/AddActivity.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.wireguard.android;
-
-import android.app.FragmentManager;
-import android.app.FragmentTransaction;
-import android.os.Bundle;
-
-import com.wireguard.config.Config;
-
-/**
- * Standalone activity for creating configurations.
- */
-
-public class AddActivity extends BaseConfigActivity {
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.add_activity);
- }
-
- @Override
- protected void onCurrentConfigChanged(final Config oldConfig, final Config newConfig) {
- // Do nothing (this never happens).
- }
-
- @Override
- protected void onEditingStateChanged(final boolean isEditing) {
- // Go back to the main activity once the new configuration is created.
- if (!isEditing)
- finish();
- }
-
- @Override
- protected void onServiceAvailable() {
- super.onServiceAvailable();
- final FragmentManager fm = getFragmentManager();
- ConfigEditFragment fragment = (ConfigEditFragment) fm.findFragmentById(R.id.master_fragment);
- if (fragment == null) {
- fragment = new ConfigEditFragment();
- final FragmentTransaction transaction = fm.beginTransaction();
- transaction.add(R.id.master_fragment, fragment);
- transaction.commit();
- }
- // Prime the state for the fragment to tell us it is finished.
- setIsEditing(true);
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/Application.java b/app/src/main/java/com/wireguard/android/Application.java
new file mode 100644
index 00000000..c0fb046a
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/Application.java
@@ -0,0 +1,124 @@
+package com.wireguard.android;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.PreferenceManager;
+
+import com.gabrielittner.threetenbp.LazyThreeTen;
+import com.wireguard.android.backend.Backend;
+import com.wireguard.android.backend.WgQuickBackend;
+import com.wireguard.android.configStore.ConfigStore;
+import com.wireguard.android.configStore.FileConfigStore;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.util.AsyncWorker;
+import com.wireguard.android.util.RootShell;
+
+import java.util.concurrent.Executor;
+
+import javax.inject.Qualifier;
+import javax.inject.Scope;
+
+import dagger.Component;
+import dagger.Module;
+import dagger.Provides;
+
+/**
+ * Base context for the WireGuard Android application. This class (instantiated once during the
+ * application lifecycle) maintains and mediates access to the global state of the application.
+ */
+
+public class Application extends android.app.Application {
+ private static ApplicationComponent component;
+
+ public static ApplicationComponent getComponent() {
+ if (component == null)
+ throw new IllegalStateException("Application instance not yet created");
+ return component;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ component = DaggerApplication_ApplicationComponent.builder()
+ .applicationModule(new ApplicationModule(this))
+ .build();
+ component.getTunnelManager().onCreate();
+ LazyThreeTen.init(this);
+ }
+
+ @ApplicationScope
+ @Component(modules = ApplicationModule.class)
+ public interface ApplicationComponent {
+ AsyncWorker getAsyncWorker();
+
+ SharedPreferences getPreferences();
+
+ TunnelManager getTunnelManager();
+ }
+
+ @Qualifier
+ public @interface ApplicationContext {
+ }
+
+ @Qualifier
+ public @interface ApplicationHandler {
+ }
+
+ @Scope
+ public @interface ApplicationScope {
+ }
+
+ @Module
+ public static final class ApplicationModule {
+ private final Context context;
+
+ private ApplicationModule(final Application application) {
+ context = application.getApplicationContext();
+ }
+
+ @ApplicationScope
+ @Provides
+ public static Backend getBackend(final AsyncWorker asyncWorker,
+ @ApplicationContext final Context context,
+ final RootShell rootShell) {
+ return new WgQuickBackend(asyncWorker, context, rootShell);
+ }
+
+ @ApplicationScope
+ @Provides
+ public static ConfigStore getConfigStore(final AsyncWorker asyncWorker,
+ @ApplicationContext final Context context) {
+ return new FileConfigStore(asyncWorker, context);
+ }
+
+
+ @ApplicationScope
+ @Provides
+ public static Executor getExecutor() {
+ return AsyncTask.SERIAL_EXECUTOR;
+ }
+
+ @ApplicationHandler
+ @ApplicationScope
+ @Provides
+ public static Handler getHandler() {
+ return new Handler(Looper.getMainLooper());
+ }
+
+ @ApplicationScope
+ @Provides
+ public static SharedPreferences getPreferences(@ApplicationContext final Context context) {
+ return PreferenceManager.getDefaultSharedPreferences(context);
+ }
+
+ @ApplicationContext
+ @ApplicationScope
+ @Provides
+ public Context getContext() {
+ return context;
+ }
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/BaseConfigActivity.java b/app/src/main/java/com/wireguard/android/BaseConfigActivity.java
deleted file mode 100644
index 1bd5c70d..00000000
--- a/app/src/main/java/com/wireguard/android/BaseConfigActivity.java
+++ /dev/null
@@ -1,103 +0,0 @@
-package com.wireguard.android;
-
-import android.app.Activity;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.os.Bundle;
-import android.os.IBinder;
-
-import com.wireguard.android.backends.VpnService;
-import com.wireguard.config.Config;
-
-/**
- * Base class for activities that need to remember the current configuration and wait for a service.
- */
-
-abstract class BaseConfigActivity extends Activity {
- protected static final String KEY_CURRENT_CONFIG = "currentConfig";
- protected static final String KEY_IS_EDITING = "isEditing";
-
- private Config currentConfig;
- private String initialConfig;
- private boolean isEditing;
- private boolean wasEditing;
-
- protected Config getCurrentConfig() {
- return currentConfig;
- }
-
- protected boolean isEditing() {
- return isEditing;
- }
-
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- // Restore the saved configuration if there is one; otherwise grab it from the intent.
- if (savedInstanceState != null) {
- initialConfig = savedInstanceState.getString(KEY_CURRENT_CONFIG);
- wasEditing = savedInstanceState.getBoolean(KEY_IS_EDITING, false);
- } else {
- final Intent intent = getIntent();
- initialConfig = intent.getStringExtra(KEY_CURRENT_CONFIG);
- wasEditing = intent.getBooleanExtra(KEY_IS_EDITING, false);
- }
- // Trigger starting the service as early as possible
- if (VpnService.getInstance() != null)
- onServiceAvailable();
- else
- bindService(new Intent(this, VpnService.class), new ServiceConnectionCallbacks(),
- Context.BIND_AUTO_CREATE);
- }
-
- protected abstract void onCurrentConfigChanged(Config oldCconfig, Config newConfig);
-
- protected abstract void onEditingStateChanged(boolean isEditing);
-
- @Override
- public void onSaveInstanceState(final Bundle outState) {
- super.onSaveInstanceState(outState);
- if (currentConfig != null)
- outState.putString(KEY_CURRENT_CONFIG, currentConfig.getName());
- outState.putBoolean(KEY_IS_EDITING, isEditing);
- }
-
- protected void onServiceAvailable() {
- // Make sure the subclass activity is initialized before setting its config.
- if (initialConfig != null && currentConfig == null)
- setCurrentConfig(VpnService.getInstance().get(initialConfig));
- setIsEditing(wasEditing);
- }
-
- public void setCurrentConfig(final Config config) {
- if (currentConfig == config)
- return;
- final Config oldConfig = currentConfig;
- currentConfig = config;
- onCurrentConfigChanged(oldConfig, config);
- }
-
- public void setIsEditing(final boolean isEditing) {
- if (this.isEditing == isEditing)
- return;
- this.isEditing = isEditing;
- onEditingStateChanged(isEditing);
- }
-
- private class ServiceConnectionCallbacks implements ServiceConnection {
- @Override
- public void onServiceConnected(final ComponentName component, final IBinder binder) {
- // We don't actually need a binding, only notification that the service is started.
- unbindService(this);
- onServiceAvailable();
- }
-
- @Override
- public void onServiceDisconnected(final ComponentName component) {
- // This can never happen; the service runs in the same thread as the activity.
- throw new IllegalStateException();
- }
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/BaseConfigFragment.java b/app/src/main/java/com/wireguard/android/BaseConfigFragment.java
deleted file mode 100644
index c92d127e..00000000
--- a/app/src/main/java/com/wireguard/android/BaseConfigFragment.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.wireguard.android;
-
-import android.app.Fragment;
-import android.os.Bundle;
-
-import com.wireguard.android.backends.VpnService;
-import com.wireguard.config.Config;
-
-/**
- * Base class for fragments that need to remember the current configuration.
- */
-
-abstract class BaseConfigFragment extends Fragment {
- private static final String KEY_CURRENT_CONFIG = "currentConfig";
-
- private Config currentConfig;
-
- protected Config getCurrentConfig() {
- return currentConfig;
- }
-
- protected abstract void onCurrentConfigChanged(Config config);
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- // Restore the saved configuration if there is one; otherwise grab it from the arguments.
- String initialConfig = null;
- if (savedInstanceState != null)
- initialConfig = savedInstanceState.getString(KEY_CURRENT_CONFIG);
- else if (getArguments() != null)
- initialConfig = getArguments().getString(KEY_CURRENT_CONFIG);
- if (initialConfig != null && currentConfig == null)
- setCurrentConfig(VpnService.getInstance().get(initialConfig));
- }
-
- @Override
- public void onSaveInstanceState(final Bundle outState) {
- super.onSaveInstanceState(outState);
- if (currentConfig != null)
- outState.putString(KEY_CURRENT_CONFIG, currentConfig.getName());
- }
-
- public void setCurrentConfig(final Config config) {
- if (currentConfig == config)
- return;
- currentConfig = config;
- onCurrentConfigChanged(currentConfig);
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java b/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java
deleted file mode 100644
index 0bf28912..00000000
--- a/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.wireguard.android;
-
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-
-import com.wireguard.android.backends.VpnService;
-
-public class BootCompletedReceiver extends BroadcastReceiver {
-
- @Override
- public void onReceive(final Context context, final Intent intent) {
- if (!intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED))
- return;
- context.startService(new Intent(context, VpnService.class));
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/BootShutdownReceiver.java b/app/src/main/java/com/wireguard/android/BootShutdownReceiver.java
new file mode 100644
index 00000000..37de83c0
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/BootShutdownReceiver.java
@@ -0,0 +1,28 @@
+package com.wireguard.android;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.util.ExceptionLoggers;
+
+public class BootShutdownReceiver extends BroadcastReceiver {
+ private static final String TAG = BootShutdownReceiver.class.getSimpleName();
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ final String action = intent.getAction();
+ if (action == null)
+ return;
+ final TunnelManager tunnelManager = Application.getComponent().getTunnelManager();
+ if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
+ Log.d(TAG, "Broadcast receiver restoring state (boot)");
+ tunnelManager.restoreState().whenComplete(ExceptionLoggers.D);
+ } else if (Intent.ACTION_SHUTDOWN.equals(action)) {
+ Log.d(TAG, "Broadcast receiver saving state (shutdown)");
+ tunnelManager.saveState().whenComplete(ExceptionLoggers.D);
+ }
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/ConfigActivity.java b/app/src/main/java/com/wireguard/android/ConfigActivity.java
deleted file mode 100644
index b92b34e3..00000000
--- a/app/src/main/java/com/wireguard/android/ConfigActivity.java
+++ /dev/null
@@ -1,289 +0,0 @@
-package com.wireguard.android;
-
-import android.app.Fragment;
-import android.app.FragmentManager;
-import android.app.FragmentTransaction;
-import android.content.Intent;
-import android.databinding.Observable;
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuItem;
-
-import com.wireguard.config.Config;
-
-/**
- * Activity that allows creating/viewing/editing/deleting WireGuard configurations.
- */
-
-public class ConfigActivity extends BaseConfigActivity {
- private static final String KEY_EDITOR_STATE = "editorState";
- private static final String TAG_DETAIL = "detail";
- private static final String TAG_EDIT = "edit";
- private static final String TAG_LIST = "list";
-
- private Fragment.SavedState editorState;
- private final FragmentManager fm = getFragmentManager();
- private final FragmentCache fragments = new FragmentCache(fm);
- private boolean isLayoutFinished;
- private boolean isServiceAvailable;
- private boolean isSingleLayout;
- private boolean isStateSaved;
- private int mainContainer;
- private Observable.OnPropertyChangedCallback nameChangeCallback;
- private String visibleFragmentTag;
-
- /**
- * Updates the fragment visible in the UI.
- * Sets visibleFragmentTag.
- *
- * @param config The config that should be visible.
- * @param tag The tag of the fragment that should be visible.
- */
- private void moveToFragment(final Config config, final String tag) {
- // Sanity check.
- if (tag == null && config != null)
- throw new IllegalArgumentException("Cannot set a config on a null fragment");
- if ((tag == null && isSingleLayout) || (TAG_LIST.equals(tag) && !isSingleLayout))
- throw new IllegalArgumentException("Requested tag " + tag + " does not match layout");
- // First tear down fragments as necessary.
- if (tag == null || TAG_LIST.equals(tag) || (TAG_DETAIL.equals(tag)
- && TAG_EDIT.equals(visibleFragmentTag))) {
- while (visibleFragmentTag != null && !visibleFragmentTag.equals(tag) &&
- fm.getBackStackEntryCount() > 0) {
- final Fragment removedFragment = fm.findFragmentById(mainContainer);
- // The fragment *must* be removed first, or it will stay attached to the layout!
- fm.beginTransaction().remove(removedFragment).commit();
- fm.popBackStackImmediate();
- // Recompute the visible fragment.
- if (TAG_EDIT.equals(visibleFragmentTag))
- visibleFragmentTag = TAG_DETAIL;
- else if (isSingleLayout && TAG_DETAIL.equals(visibleFragmentTag))
- visibleFragmentTag = TAG_LIST;
- else
- throw new IllegalStateException();
- }
- }
- // Now build up intermediate entries in the back stack as necessary.
- if (TAG_EDIT.equals(tag) && !TAG_EDIT.equals(visibleFragmentTag) &&
- !TAG_DETAIL.equals(visibleFragmentTag))
- moveToFragment(config, TAG_DETAIL);
- // Finally, set the main container's content to the new top-level fragment.
- if (tag == null) {
- if (visibleFragmentTag != null) {
- final BaseConfigFragment fragment = fragments.get(visibleFragmentTag);
- fm.beginTransaction().remove(fragment).commit();
- fm.executePendingTransactions();
- visibleFragmentTag = null;
- }
- } else if (!TAG_LIST.equals(tag)) {
- final BaseConfigFragment fragment = fragments.get(tag);
- if (!tag.equals(visibleFragmentTag)) {
- // Restore any saved editor state the first time its fragment is added.
- if (TAG_EDIT.equals(tag) && editorState != null) {
- fragment.setInitialSavedState(editorState);
- editorState = null;
- }
- final FragmentTransaction transaction = fm.beginTransaction();
- if (TAG_EDIT.equals(tag) || (isSingleLayout && TAG_DETAIL.equals(tag))) {
- transaction.addToBackStack(null);
- transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
- }
- transaction.replace(mainContainer, fragment, tag).commit();
- visibleFragmentTag = tag;
- }
- if (fragment.getCurrentConfig() != config)
- fragment.setCurrentConfig(config);
- }
- }
-
- /**
- * Transition the state machine to the desired state, if possible.
- * Sets currentConfig and isEditing.
- *
- * @param config The desired config to show in the UI.
- * @param shouldBeEditing Whether or not the config should be in the editing state.
- */
- private void moveToState(final Config config, final boolean shouldBeEditing) {
- // Update the saved state.
- setCurrentConfig(config);
- setIsEditing(shouldBeEditing);
- // Avoid performing fragment transactions when the app is not fully initialized.
- if (!isLayoutFinished || !isServiceAvailable || isStateSaved)
- return;
- // Ensure the list is present in the master pane. It will be restored on activity restarts!
- final BaseConfigFragment listFragment = fragments.get(TAG_LIST);
- if (fm.findFragmentById(R.id.master_fragment) == null)
- fm.beginTransaction().add(R.id.master_fragment, listFragment, TAG_LIST).commit();
- // In the single-pane layout, the main container starts holding the list fragment.
- if (isSingleLayout && visibleFragmentTag == null)
- visibleFragmentTag = TAG_LIST;
- // Forward any config changes to the list (they may have come from the intent or editing).
- listFragment.setCurrentConfig(config);
- // Ensure the correct main fragment is visible, adjusting the back stack as necessary.
- moveToFragment(config, shouldBeEditing ? TAG_EDIT :
- (config != null ? TAG_DETAIL : (isSingleLayout ? TAG_LIST : null)));
- // Show the current config as the title if the list of configurations is not visible.
- setTitle(isSingleLayout && config != null ? config.getName() : getString(R.string.app_name));
- // Show or hide the action bar back button if the back stack is not empty.
- if (getActionBar() != null) {
- getActionBar().setDisplayHomeAsUpEnabled(config != null &&
- (isSingleLayout || shouldBeEditing));
- }
- }
-
- @Override
- public void onBackPressed() {
- final ConfigListFragment listFragment = (ConfigListFragment) fragments.get(TAG_LIST);
- if (listFragment.isVisible() && listFragment.tryCollapseMenu())
- return;
- super.onBackPressed();
- // The visible fragment is now the one that was on top of the back stack, if there was one.
- if (isEditing())
- visibleFragmentTag = TAG_DETAIL;
- else if (isSingleLayout && TAG_DETAIL.equals(visibleFragmentTag))
- visibleFragmentTag = TAG_LIST;
- // If the user went back from the detail screen to the list, clear the current config.
- moveToState(isEditing() ? getCurrentConfig() : null, false);
- }
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- if (savedInstanceState != null)
- editorState = savedInstanceState.getParcelable(KEY_EDITOR_STATE);
- setContentView(R.layout.config_activity);
- isSingleLayout = findViewById(R.id.detail_fragment) == null;
- mainContainer = isSingleLayout ? R.id.master_fragment : R.id.detail_fragment;
- if (isSingleLayout) {
- nameChangeCallback = new ConfigNameChangeCallback();
- if (getCurrentConfig() != null)
- getCurrentConfig().addOnPropertyChangedCallback(nameChangeCallback);
- }
- isLayoutFinished = true;
- moveToState(getCurrentConfig(), isEditing());
- }
-
- @Override
- public boolean onCreateOptionsMenu(final Menu menu) {
- getMenuInflater().inflate(R.menu.main, menu);
- return true;
- }
-
- @Override
- protected void onCurrentConfigChanged(final Config oldConfig, final Config newConfig) {
- if (nameChangeCallback != null && oldConfig != null)
- oldConfig.removeOnPropertyChangedCallback(nameChangeCallback);
- // Abandon editing a config when the current config changes.
- moveToState(newConfig, false);
- if (nameChangeCallback != null && newConfig != null)
- newConfig.addOnPropertyChangedCallback(nameChangeCallback);
- }
-
- @Override
- protected void onEditingStateChanged(final boolean isEditing) {
- moveToState(getCurrentConfig(), isEditing);
- }
-
- @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:
- // Try to make the editing fragment visible.
- setIsEditing(true);
- return true;
- case R.id.menu_action_save:
- // This menu item is handled by the editing fragment.
- return false;
- case R.id.menu_settings:
- startActivity(new Intent(this, SettingsActivity.class));
- return true;
- default:
- return super.onOptionsItemSelected(item);
- }
- }
-
- @Override
- public void onPostResume() {
- super.onPostResume();
- // Allow changes to fragments.
- isStateSaved = false;
- moveToState(getCurrentConfig(), isEditing());
- }
-
- @Override
- public void onSaveInstanceState(final Bundle outState) {
- // We cannot save fragments that might switch between containers if the layout changes.
- if (isLayoutFinished && isServiceAvailable && !isStateSaved) {
- // Save the editor state before destroying it.
- if (TAG_EDIT.equals(visibleFragmentTag)) {
- // For the case where the activity is resumed.
- editorState = fm.saveFragmentInstanceState(fragments.get(TAG_EDIT));
- // For the case where the activity is restarted.
- outState.putParcelable(KEY_EDITOR_STATE, editorState);
- }
- moveToFragment(null, isSingleLayout ? TAG_LIST : null);
- }
- // Prevent further changes to fragments.
- isStateSaved = true;
- super.onSaveInstanceState(outState);
- }
-
- @Override
- protected void onServiceAvailable() {
- super.onServiceAvailable();
- // Allow creating fragments.
- isServiceAvailable = true;
- moveToState(getCurrentConfig(), isEditing());
- }
-
- private class ConfigNameChangeCallback extends Observable.OnPropertyChangedCallback {
- @Override
- public void onPropertyChanged(final Observable sender, final int propertyId) {
- if (sender != getCurrentConfig())
- sender.removeOnPropertyChangedCallback(this);
- if (propertyId != 0 && propertyId != BR.name)
- return;
- setTitle(getCurrentConfig().getName());
- }
- }
-
- private static class FragmentCache {
- private ConfigDetailFragment detailFragment;
- private ConfigEditFragment editFragment;
- private final FragmentManager fm;
- private ConfigListFragment listFragment;
-
- private FragmentCache(final FragmentManager fm) {
- this.fm = fm;
- }
-
- private BaseConfigFragment get(final String tag) {
- switch (tag) {
- case TAG_DETAIL:
- if (detailFragment == null)
- detailFragment = (ConfigDetailFragment) fm.findFragmentByTag(tag);
- if (detailFragment == null)
- detailFragment = new ConfigDetailFragment();
- return detailFragment;
- case TAG_EDIT:
- if (editFragment == null)
- editFragment = (ConfigEditFragment) fm.findFragmentByTag(tag);
- if (editFragment == null)
- editFragment = new ConfigEditFragment();
- return editFragment;
- case TAG_LIST:
- if (listFragment == null)
- listFragment = (ConfigListFragment) fm.findFragmentByTag(tag);
- if (listFragment == null)
- listFragment = new ConfigListFragment();
- return listFragment;
- default:
- throw new IllegalArgumentException();
- }
- }
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/ConfigDetailFragment.java b/app/src/main/java/com/wireguard/android/ConfigDetailFragment.java
deleted file mode 100644
index d6c02935..00000000
--- a/app/src/main/java/com/wireguard/android/ConfigDetailFragment.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.wireguard.android;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import com.wireguard.android.databinding.ConfigDetailFragmentBinding;
-import com.wireguard.config.Config;
-
-/**
- * Fragment for viewing information about a WireGuard configuration.
- */
-
-public class ConfigDetailFragment extends BaseConfigFragment {
- private ConfigDetailFragmentBinding binding;
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- }
-
- @Override
- public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
- inflater.inflate(R.menu.config_detail, menu);
- }
-
- @Override
- public View onCreateView(final LayoutInflater inflater, final ViewGroup parent,
- final Bundle savedInstanceState) {
- binding = ConfigDetailFragmentBinding.inflate(inflater, parent, false);
- binding.setConfig(getCurrentConfig());
- return binding.getRoot();
- }
-
- @Override
- protected void onCurrentConfigChanged(final Config config) {
- if (binding != null)
- binding.setConfig(config);
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/ConfigEditFragment.java b/app/src/main/java/com/wireguard/android/ConfigEditFragment.java
deleted file mode 100644
index f29f20fe..00000000
--- a/app/src/main/java/com/wireguard/android/ConfigEditFragment.java
+++ /dev/null
@@ -1,139 +0,0 @@
-package com.wireguard.android;
-
-import android.app.Activity;
-import android.content.ClipData;
-import android.content.ClipboardManager;
-import android.content.Context;
-import android.os.Bundle;
-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.backends.VpnService;
-import com.wireguard.android.databinding.ConfigEditFragmentBinding;
-import com.wireguard.config.Config;
-
-/**
- * Fragment for editing a WireGuard configuration.
- */
-
-public class ConfigEditFragment extends BaseConfigFragment {
- private static final String KEY_MODIFIED_CONFIG = "modifiedConfig";
- private static final String KEY_ORIGINAL_NAME = "originalName";
-
- public static void copyPublicKey(final Context context, final String publicKey) {
- if (publicKey == null || publicKey.isEmpty())
- return;
- final ClipboardManager clipboard =
- (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
- final String description =
- context.getResources().getString(R.string.public_key_description);
- clipboard.setPrimaryClip(ClipData.newPlainText(description, publicKey));
- final String message = context.getResources().getString(R.string.public_key_copied_message);
- Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
- }
-
- private Config localConfig;
- private String originalName;
-
- @Override
- protected void onCurrentConfigChanged(final Config config) {
- // Only discard modifications when the config *they are based on* changes.
- if (config == null || config.getName().equals(originalName) || localConfig == null)
- return;
- localConfig.copyFrom(config);
- originalName = config.getName();
- }
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- // Restore more saved information.
- if (savedInstanceState != null) {
- localConfig = savedInstanceState.getParcelable(KEY_MODIFIED_CONFIG);
- originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME);
- } else if (getArguments() != null) {
- final Bundle arguments = getArguments();
- localConfig = arguments.getParcelable(KEY_MODIFIED_CONFIG);
- originalName = arguments.getString(KEY_ORIGINAL_NAME);
- }
- if (localConfig == null) {
- localConfig = new Config();
- originalName = null;
- }
- onCurrentConfigChanged(getCurrentConfig());
- setHasOptionsMenu(true);
- }
-
- @Override
- public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
- inflater.inflate(R.menu.config_edit, menu);
- }
-
- @Override
- public View onCreateView(final LayoutInflater inflater, final ViewGroup parent,
- final Bundle savedInstanceState) {
- final ConfigEditFragmentBinding binding =
- ConfigEditFragmentBinding.inflate(inflater, parent, false);
- binding.setConfig(localConfig);
- return binding.getRoot();
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- // Reset changes to the config when the user cancels editing. See also the comment below.
- if (isRemoving())
- localConfig.copyFrom(getCurrentConfig());
- }
-
- @Override
- public boolean onOptionsItemSelected(final MenuItem item) {
- switch (item.getItemId()) {
- case R.id.menu_action_save:
- saveConfig();
- return true;
- default:
- return super.onOptionsItemSelected(item);
- }
- }
-
- @Override
- public void onSaveInstanceState(final Bundle outState) {
- super.onSaveInstanceState(outState);
- // When ConfigActivity unwinds the back stack, isRemoving() is true, so localConfig will be
- // reset. Since outState is not serialized yet, it resets the saved config too. Avoid this
- // by copying the local config. originalName is fine because it is replaced, not modified.
- outState.putParcelable(KEY_MODIFIED_CONFIG, localConfig.copy());
- outState.putString(KEY_ORIGINAL_NAME, originalName);
- }
-
- private void saveConfig() {
- final VpnService service = VpnService.getInstance();
- try {
- if (getCurrentConfig() != null)
- service.update(getCurrentConfig().getName(), localConfig);
- else
- service.add(localConfig);
- } catch (final IllegalArgumentException | IllegalStateException e) {
- Toast.makeText(getActivity(), e.getMessage(), Toast.LENGTH_SHORT).show();
- return;
- }
- // Hide the keyboard; it rarely goes away on its own.
- final Activity activity = getActivity();
- final View focusedView = activity.getCurrentFocus();
- if (focusedView != null) {
- final InputMethodManager inputManager =
- (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
- inputManager.hideSoftInputFromWindow(focusedView.getWindowToken(),
- InputMethodManager.HIDE_NOT_ALWAYS);
- }
- // Tell the activity to finish itself or go back to the detail view.
- ((BaseConfigActivity) activity).setIsEditing(false);
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/ConfigListFragment.java b/app/src/main/java/com/wireguard/android/ConfigListFragment.java
deleted file mode 100644
index b7460134..00000000
--- a/app/src/main/java/com/wireguard/android/ConfigListFragment.java
+++ /dev/null
@@ -1,198 +0,0 @@
-package com.wireguard.android;
-
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.content.Intent;
-import android.content.res.Resources;
-import android.os.Bundle;
-import android.view.ActionMode;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AbsListView;
-import android.widget.AdapterView;
-
-import com.wireguard.android.backends.VpnService;
-import com.wireguard.android.databinding.ObservableMapAdapter;
-import com.wireguard.android.databinding.ConfigListFragmentBinding;
-import com.wireguard.config.Config;
-
-import java.util.LinkedList;
-import java.util.List;
-
-/**
- * Fragment containing the list of known WireGuard configurations.
- */
-
-public class ConfigListFragment extends BaseConfigFragment {
- private static final int REQUEST_IMPORT = 1;
-
- private ConfigListFragmentBinding binding;
-
- @Override
- public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
- if (requestCode == REQUEST_IMPORT) {
- if (resultCode == Activity.RESULT_OK)
- VpnService.getInstance().importFrom(data.getData());
- } else {
- super.onActivityResult(requestCode, resultCode, data);
- }
- }
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- }
-
- @Override
- public View onCreateView(final LayoutInflater inflater, final ViewGroup parent,
- final Bundle savedInstanceState) {
- binding = ConfigListFragmentBinding.inflate(inflater, parent, false);
- binding.setConfigs(VpnService.getInstance().getConfigs());
- binding.addFromFile.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(final View view) {
- final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
- intent.addCategory(Intent.CATEGORY_OPENABLE);
- intent.setType("*/*");
- startActivityForResult(intent, REQUEST_IMPORT);
- binding.addMenu.collapse();
- }
- });
- binding.addFromScratch.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(final View view) {
- startActivity(new Intent(getActivity(), AddActivity.class));
- binding.addMenu.collapse();
- }
- });
- binding.configList.setMultiChoiceModeListener(new ConfigListModeListener());
- binding.configList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
- @Override
- public void onItemClick(final AdapterView<?> parent, final View view,
- final int position, final long id) {
- final Config config = (Config) parent.getItemAtPosition(position);
- setCurrentConfig(config);
- }
- });
- binding.configList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
- @Override
- public boolean onItemLongClick(final AdapterView<?> parent, final View view,
- final int position, final long id) {
- setConfigChecked(null);
- binding.configList.setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE_MODAL);
- binding.configList.setItemChecked(position, true);
- return true;
- }
- });
- binding.configList.setOnTouchListener(new View.OnTouchListener() {
- @Override
- @SuppressLint("ClickableViewAccessibility")
- public boolean onTouch(final View view, final MotionEvent event) {
- binding.addMenu.collapse();
- return false;
- }
- });
- binding.executePendingBindings();
- setConfigChecked(getCurrentConfig());
- return binding.getRoot();
- }
-
- @Override
- protected void onCurrentConfigChanged(final Config config) {
- final BaseConfigActivity activity = ((BaseConfigActivity) getActivity());
- if (activity != null)
- activity.setCurrentConfig(config);
- if (binding != null)
- setConfigChecked(config);
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
- binding = null;
- }
-
- private void setConfigChecked(final Config config) {
- if (config != null) {
- @SuppressWarnings("unchecked") final ObservableMapAdapter<String, Config> adapter =
- (ObservableMapAdapter<String, Config>) binding.configList.getAdapter();
- final int position = adapter.getPosition(config.getName());
- if (position >= 0)
- binding.configList.setItemChecked(position, true);
- } else {
- final int position = binding.configList.getCheckedItemPosition();
- if (position >= 0)
- binding.configList.setItemChecked(position, false);
- }
- }
-
- public boolean tryCollapseMenu() {
- if (binding != null && binding.addMenu.isExpanded()) {
- binding.addMenu.collapse();
- return true;
- }
- return false;
- }
-
- private class ConfigListModeListener implements AbsListView.MultiChoiceModeListener {
- private final List<Config> configsToRemove = new LinkedList<>();
-
- @Override
- public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) {
- switch (item.getItemId()) {
- case R.id.menu_action_delete:
- // Ensure an unmanaged config is never the current config.
- if (configsToRemove.contains(getCurrentConfig()))
- setCurrentConfig(null);
- for (final Config config : configsToRemove)
- VpnService.getInstance().remove(config.getName());
- configsToRemove.clear();
- mode.finish();
- return true;
- default:
- return false;
- }
- }
-
- @Override
- public void onItemCheckedStateChanged(final ActionMode mode, final int position,
- final long id, final boolean checked) {
- if (checked)
- configsToRemove.add((Config) binding.configList.getItemAtPosition(position));
- else
- configsToRemove.remove(binding.configList.getItemAtPosition(position));
- final int count = configsToRemove.size();
- final Resources resources = binding.getRoot().getContext().getResources();
- mode.setTitle(resources.getQuantityString(R.plurals.list_delete_title, count, count));
- }
-
- @Override
- public boolean onCreateActionMode(final ActionMode mode, final Menu menu) {
- mode.getMenuInflater().inflate(R.menu.config_list_delete, menu);
- return true;
- }
-
- @Override
- public void onDestroyActionMode(final ActionMode mode) {
- configsToRemove.clear();
- binding.configList.post(new Runnable() {
- @Override
- public void run() {
- binding.configList.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
- // Restore the previous selection (before entering the action mode).
- setConfigChecked(getCurrentConfig());
- }
- });
- }
-
- @Override
- public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) {
- configsToRemove.clear();
- return false;
- }
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/NotSupportedActivity.java b/app/src/main/java/com/wireguard/android/NotSupportedActivity.java
deleted file mode 100644
index 7b4d0bab..00000000
--- a/app/src/main/java/com/wireguard/android/NotSupportedActivity.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.wireguard.android;
-
-import android.app.Activity;
-import android.databinding.DataBindingUtil;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.Html;
-import android.text.Spanned;
-import android.text.method.LinkMovementMethod;
-
-import com.wireguard.android.databinding.NotSupportedActivityBinding;
-
-public class NotSupportedActivity extends Activity {
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- final NotSupportedActivityBinding binding =
- DataBindingUtil.setContentView(this, R.layout.not_supported_activity);
- final String messageHtml = getString(R.string.not_supported_message);
- final Spanned messageText;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
- messageText = Html.fromHtml(messageHtml, Html.FROM_HTML_MODE_COMPACT);
- else
- messageText = Html.fromHtml(messageHtml);
- binding.notSupportedMessage.setMovementMethod(LinkMovementMethod.getInstance());
- binding.notSupportedMessage.setText(messageText);
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/QuickTileService.java b/app/src/main/java/com/wireguard/android/QuickTileService.java
index e512b1ad..9de4322a 100644
--- a/app/src/main/java/com/wireguard/android/QuickTileService.java
+++ b/app/src/main/java/com/wireguard/android/QuickTileService.java
@@ -1,40 +1,57 @@
package com.wireguard.android;
import android.annotation.TargetApi;
-import android.content.ComponentName;
-import android.content.Context;
import android.content.Intent;
-import android.content.ServiceConnection;
import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.databinding.Observable;
+import android.databinding.Observable.OnPropertyChangedCallback;
+import android.databinding.ObservableMap.OnMapChangedCallback;
import android.graphics.drawable.Icon;
import android.os.Build;
-import android.os.IBinder;
-import android.preference.PreferenceManager;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
+import android.util.Log;
+import android.widget.Toast;
-import com.wireguard.android.backends.VpnService;
-import com.wireguard.config.Config;
+import com.wireguard.android.Application.ApplicationComponent;
+import com.wireguard.android.activity.MainActivity;
+import com.wireguard.android.activity.SettingsActivity;
+import com.wireguard.android.model.Tunnel;
+import com.wireguard.android.model.Tunnel.State;
+import com.wireguard.android.model.TunnelCollection;
+import com.wireguard.android.model.TunnelManager;
+
+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.
+ */
@TargetApi(Build.VERSION_CODES.N)
-public class QuickTileService extends TileService {
- private Config config;
+public class QuickTileService extends TileService implements OnSharedPreferenceChangeListener {
+ private static final String TAG = QuickTileService.class.getSimpleName();
+
+ private final OnTunnelStateChangedCallback tunnelCallback = new OnTunnelStateChangedCallback();
+ private final OnTunnelMapChangedCallback tunnelMapCallback = new OnTunnelMapChangedCallback();
private SharedPreferences preferences;
- private VpnService service;
+ private Tunnel tunnel;
+ private TunnelManager tunnelManager;
@Override
public void onClick() {
- if (service != null && config != null) {
- if (config.isEnabled())
- service.disable(config.getName());
- else
- service.enable(config.getName());
+ if (tunnel != null) {
+ tunnel.setState(State.TOGGLE).handle(this::onToggleFinished);
} else {
- if (service != null && service.getConfigs().isEmpty()) {
- startActivityAndCollapse(new Intent(this, ConfigActivity.class));
+ if (tunnelManager.getTunnels().isEmpty()) {
+ // Prompt the user to create or import a tunnel configuration.
+ startActivityAndCollapse(new Intent(this, MainActivity.class));
} else {
+ // Prompt the user to select a tunnel for use with the quick settings tile.
final Intent intent = new Intent(this, SettingsActivity.class);
- intent.putExtra("showQuickTile", true);
+ intent.putExtra(SettingsActivity.KEY_SHOW_QUICK_TILE_SETTINGS, true);
startActivityAndCollapse(intent);
}
}
@@ -42,50 +59,101 @@ public class QuickTileService extends TileService {
@Override
public void onCreate() {
- preferences = PreferenceManager.getDefaultSharedPreferences(this);
- service = VpnService.getInstance();
- if (service == null)
- bindService(new Intent(this, VpnService.class), new ServiceConnectionCallbacks(),
- Context.BIND_AUTO_CREATE);
- TileService.requestListeningState(this, new ComponentName(this, getClass()));
+ super.onCreate();
+ final ApplicationComponent component = Application.getComponent();
+ preferences = component.getPreferences();
+ tunnelManager = component.getTunnelManager();
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences preferences, final String key) {
+ if (!TunnelManager.KEY_PRIMARY_TUNNEL.equals(key))
+ return;
+ updateTile();
}
@Override
public void onStartListening() {
- // Since this is an active tile, this only gets called when we want to update the tile.
+ preferences.registerOnSharedPreferenceChangeListener(this);
+ tunnelManager.getTunnels().addOnMapChangedCallback(tunnelMapCallback);
+ if (tunnel != null)
+ tunnel.addOnPropertyChangedCallback(tunnelCallback);
+ updateTile();
+ }
+
+ @Override
+ public void onStopListening() {
+ preferences.unregisterOnSharedPreferenceChangeListener(this);
+ tunnelManager.getTunnels().removeOnMapChangedCallback(tunnelMapCallback);
+ if (tunnel != null)
+ tunnel.removeOnPropertyChangedCallback(tunnelCallback);
+ }
+
+ @SuppressWarnings("unused")
+ private Void onToggleFinished(final State state, final Throwable throwable) {
+ if (throwable == null)
+ return null;
+ Log.e(TAG, "Cannot toggle tunnel", throwable);
+ final String message = "Cannot toggle tunnel: " + throwable.getCause().getMessage();
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
+ return null;
+ }
+
+ private void updateTile() {
+ // Update the tunnel.
+ final String currentName = tunnel != null ? tunnel.getName() : null;
+ final String newName = preferences.getString(TunnelManager.KEY_PRIMARY_TUNNEL, null);
+ if (!Objects.equals(currentName, newName)) {
+ final TunnelCollection tunnels = tunnelManager.getTunnels();
+ final Tunnel newTunnel = newName != null ? tunnels.get(newName) : null;
+ if (tunnel != null)
+ tunnel.removeOnPropertyChangedCallback(tunnelCallback);
+ tunnel = newTunnel;
+ if (tunnel != null)
+ tunnel.addOnPropertyChangedCallback(tunnelCallback);
+ }
+ // Update the tile contents.
+ final String label;
+ final int state;
final Tile tile = getQsTile();
- final String configName = preferences.getString(VpnService.KEY_PRIMARY_CONFIG, null);
- config = configName != null && service != null ? service.get(configName) : null;
- if (config != null) {
- tile.setLabel(config.getName());
- final int state = config.isEnabled() ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE;
- if (tile.getState() != state) {
- // The icon must be changed every time the state changes, or the color won't change.
- final Integer iconResource = (state == Tile.STATE_ACTIVE) ?
- R.drawable.ic_tile : R.drawable.ic_tile_disabled;
- tile.setIcon(Icon.createWithResource(this, iconResource));
- tile.setState(state);
- }
+ if (tunnel != null) {
+ label = tunnel.getName();
+ state = tunnel.getState() == Tunnel.State.UP ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE;
} else {
- tile.setIcon(Icon.createWithResource(this, R.drawable.ic_tile_disabled));
- tile.setLabel(getString(R.string.app_name));
- tile.setState(Tile.STATE_INACTIVE);
+ label = getString(R.string.app_name);
+ state = Tile.STATE_INACTIVE;
+ }
+ tile.setLabel(label);
+ if (tile.getState() != state) {
+ // The icon must be changed every time the state changes, or the shade will not change.
+ final Integer iconResource = (state == Tile.STATE_ACTIVE)
+ ? R.drawable.ic_tile : R.drawable.ic_tile_disabled;
+ tile.setIcon(Icon.createWithResource(this, iconResource));
+ tile.setState(state);
}
tile.updateTile();
}
- private class ServiceConnectionCallbacks implements ServiceConnection {
+ private final class OnTunnelMapChangedCallback
+ extends OnMapChangedCallback<TunnelCollection, String, Tunnel> {
@Override
- public void onServiceConnected(final ComponentName component, final IBinder binder) {
- // We don't actually need a binding, only notification that the service is started.
- unbindService(this);
- service = VpnService.getInstance();
+ public void onMapChanged(final TunnelCollection sender, final String key) {
+ if (!key.equals(preferences.getString(TunnelManager.KEY_PRIMARY_TUNNEL, null)))
+ return;
+ updateTile();
}
+ }
+ private final class OnTunnelStateChangedCallback extends OnPropertyChangedCallback {
@Override
- public void onServiceDisconnected(final ComponentName component) {
- // This can never happen; the service runs in the same thread as this service.
- throw new IllegalStateException();
+ 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();
}
}
}
diff --git a/app/src/main/java/com/wireguard/android/activity/BaseActivity.java b/app/src/main/java/com/wireguard/android/activity/BaseActivity.java
new file mode 100644
index 00000000..4bd3407e
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/activity/BaseActivity.java
@@ -0,0 +1,94 @@
+package com.wireguard.android.activity;
+
+import android.app.Activity;
+import android.databinding.CallbackRegistry;
+import android.databinding.CallbackRegistry.NotifierCallback;
+import android.os.Bundle;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.model.Tunnel;
+import com.wireguard.android.model.TunnelManager;
+
+import java.util.Objects;
+
+/**
+ * Base class for activities that need to remember the currently-selected tunnel.
+ */
+
+public abstract class BaseActivity extends Activity {
+ private static final String TAG = BaseActivity.class.getSimpleName();
+
+ private final SelectionChangeRegistry selectionChangeRegistry = new SelectionChangeRegistry();
+ private Tunnel selectedTunnel;
+
+ public void addOnSelectedTunnelChangedListener(
+ final OnSelectedTunnelChangedListener listener) {
+ selectionChangeRegistry.add(listener);
+ }
+
+ public Tunnel getSelectedTunnel() {
+ return selectedTunnel;
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ // Restore the saved tunnel if there is one; otherwise grab it from the arguments.
+ String savedTunnelName = null;
+ if (savedInstanceState != null)
+ savedTunnelName = savedInstanceState.getString(TunnelManager.KEY_SELECTED_TUNNEL);
+ else if (getIntent() != null)
+ savedTunnelName = getIntent().getStringExtra(TunnelManager.KEY_SELECTED_TUNNEL);
+ if (savedTunnelName != null) {
+ final TunnelManager manager = Application.getComponent().getTunnelManager();
+ selectedTunnel = manager.getTunnels().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(TunnelManager.KEY_SELECTED_TUNNEL, selectedTunnel.getName());
+ super.onSaveInstanceState(outState);
+ }
+
+ protected abstract Tunnel onSelectedTunnelChanged(Tunnel oldTunnel, Tunnel newTunnel);
+
+ public void removeOnSelectedTunnelChangedListener(
+ final OnSelectedTunnelChangedListener listener) {
+ selectionChangeRegistry.remove(listener);
+ }
+
+ public void setSelectedTunnel(final Tunnel tunnel) {
+ final Tunnel oldTunnel = selectedTunnel;
+ if (Objects.equals(oldTunnel, tunnel))
+ return;
+ // Give the activity a chance to override the tunnel change.
+ selectedTunnel = onSelectedTunnelChanged(oldTunnel, tunnel);
+ if (Objects.equals(oldTunnel, selectedTunnel))
+ return;
+ selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, selectedTunnel);
+ }
+
+ public interface OnSelectedTunnelChangedListener {
+ void onSelectedTunnelChanged(Tunnel oldTunnel, Tunnel newTunnel);
+ }
+
+ private static final class SelectionChangeNotifier
+ extends NotifierCallback<OnSelectedTunnelChangedListener, Tunnel, Tunnel> {
+ @Override
+ public void onNotifyCallback(final OnSelectedTunnelChangedListener listener,
+ final Tunnel oldTunnel, final int ignored,
+ final Tunnel newTunnel) {
+ listener.onSelectedTunnelChanged(oldTunnel, newTunnel);
+ }
+ }
+
+ private static final class SelectionChangeRegistry
+ extends CallbackRegistry<OnSelectedTunnelChangedListener, Tunnel, Tunnel> {
+ private SelectionChangeRegistry() {
+ super(new SelectionChangeNotifier());
+ }
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/activity/MainActivity.java b/app/src/main/java/com/wireguard/android/activity/MainActivity.java
new file mode 100644
index 00000000..2d06a9a9
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/activity/MainActivity.java
@@ -0,0 +1,146 @@
+package com.wireguard.android.activity;
+
+import android.app.Fragment;
+import android.app.FragmentTransaction;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import com.wireguard.android.R;
+import com.wireguard.android.fragment.ConfigEditorFragment;
+import com.wireguard.android.fragment.TunnelDetailFragment;
+import com.wireguard.android.fragment.TunnelListFragment;
+import com.wireguard.android.model.Tunnel;
+
+import java9.util.stream.Stream;
+
+/**
+ * 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 {
+ private static final String KEY_STATE = "fragment_state";
+ private static final String TAG = MainActivity.class.getSimpleName();
+ private State state = State.EMPTY;
+
+ private boolean moveToState(final State nextState) {
+ Log.i(TAG, "Moving from " + state.name() + " to " + nextState.name());
+ if (nextState == state) {
+ return false;
+ } else if (nextState.layer > state.layer + 1) {
+ moveToState(State.ofLayer(state.layer + 1));
+ moveToState(nextState);
+ return true;
+ } else if (nextState.layer == state.layer + 1) {
+ final Fragment fragment = Fragment.instantiate(this, nextState.fragment);
+ final FragmentTransaction transaction = getFragmentManager().beginTransaction()
+ .replace(R.id.master_fragment, fragment)
+ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
+ if (state.layer > 0)
+ transaction.addToBackStack(null);
+ transaction.commit();
+ } else if (nextState.layer == state.layer - 1) {
+ if (getFragmentManager().getBackStackEntryCount() == 0)
+ return false;
+ getFragmentManager().popBackStack();
+ } else if (nextState.layer < state.layer - 1) {
+ moveToState(State.ofLayer(state.layer - 1));
+ moveToState(nextState);
+ return true;
+ }
+ state = nextState;
+ if (state.layer > 1) {
+ if (getActionBar() != null)
+ getActionBar().setDisplayHomeAsUpEnabled(true);
+ } else {
+ if (getActionBar() != null)
+ getActionBar().setDisplayHomeAsUpEnabled(false);
+ setSelectedTunnel(null);
+ }
+ return true;
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (!moveToState(State.ofLayer(state.layer - 1)))
+ super.onBackPressed();
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main_activity);
+ if (savedInstanceState != null && savedInstanceState.getString(KEY_STATE) != null)
+ state = State.valueOf(savedInstanceState.getString(KEY_STATE));
+ if (state == State.EMPTY) {
+ State initialState = getSelectedTunnel() != null ? State.DETAIL : State.LIST;
+ if (getIntent() != null && getIntent().getStringExtra(KEY_STATE) != null)
+ initialState = State.valueOf(getIntent().getStringExtra(KEY_STATE));
+ moveToState(initialState);
+ }
+ }
+
+ @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.
+ moveToState(State.ofLayer(state.layer - 1));
+ return true;
+ case R.id.menu_action_edit:
+ if (getSelectedTunnel() != null)
+ moveToState(State.EDITOR);
+ 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 onSaveInstanceState(final Bundle outState) {
+ outState.putString(KEY_STATE, state.name());
+ super.onSaveInstanceState(outState);
+ }
+
+
+ @Override
+ protected Tunnel onSelectedTunnelChanged(final Tunnel oldTunnel, final Tunnel newTunnel) {
+ moveToState(newTunnel != null ? State.DETAIL : State.LIST);
+ return newTunnel;
+ }
+
+ private enum State {
+ EMPTY(null, 0),
+ LIST(TunnelListFragment.class, 1),
+ DETAIL(TunnelDetailFragment.class, 2),
+ EDITOR(ConfigEditorFragment.class, 3);
+
+ private final String fragment;
+ private final int layer;
+
+ State(final Class<? extends Fragment> fragment, final int layer) {
+ this.fragment = fragment != null ? fragment.getName() : null;
+ this.layer = layer;
+ }
+
+ private static State ofLayer(final int layer) {
+ return Stream.of(State.values()).filter(s -> s.layer == layer).findFirst().get();
+ }
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/SettingsActivity.java b/app/src/main/java/com/wireguard/android/activity/SettingsActivity.java
index 70912a48..c6c69789 100644
--- a/app/src/main/java/com/wireguard/android/SettingsActivity.java
+++ b/app/src/main/java/com/wireguard/android/activity/SettingsActivity.java
@@ -1,23 +1,34 @@
-package com.wireguard.android;
+package com.wireguard.android.activity;
import android.app.Activity;
-import android.app.FragmentTransaction;
+import android.app.Fragment;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import android.preference.Preference;
import android.preference.PreferenceFragment;
-import com.wireguard.android.backends.RootShell;
+import com.wireguard.android.R;
+import com.wireguard.android.preference.TunnelListPreference;
+import com.wireguard.android.util.RootShell;
+
+/**
+ * Interface for changing application-global persistent settings.
+ */
public class SettingsActivity extends Activity {
+ public static final String KEY_SHOW_QUICK_TILE_SETTINGS = "show_quick_tile_settings";
+
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- final FragmentTransaction transaction = getFragmentManager().beginTransaction();
- final SettingsFragment fragment = new SettingsFragment();
- fragment.setArguments(getIntent().getExtras());
- transaction.replace(android.R.id.content, fragment).commit();
+ if (getFragmentManager().findFragmentById(android.R.id.content) == null) {
+ final Fragment fragment = new SettingsFragment();
+ fragment.setArguments(getIntent().getExtras());
+ getFragmentManager().beginTransaction()
+ .add(android.R.id.content, fragment)
+ .commit();
+ }
}
public static class SettingsFragment extends PreferenceFragment {
@@ -25,44 +36,42 @@ public class SettingsActivity extends Activity {
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.preferences);
- if (getArguments() != null && getArguments().getBoolean("showQuickTile"))
- ((ConfigListPreference) findPreference("primary_config")).show();
-
final Preference installTools = findPreference("install_cmd_line_tools");
- installTools.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
- public boolean onPreferenceClick(Preference preference) {
- new ToolsInstaller(installTools).execute();
- return true;
- }
+ installTools.setOnPreferenceClickListener(preference -> {
+ new ToolsInstaller(preference).execute();
+ return true;
});
+ if (getArguments() != null && getArguments().getBoolean(KEY_SHOW_QUICK_TILE_SETTINGS))
+ ((TunnelListPreference) findPreference("primary_config")).show();
}
}
- private static class ToolsInstaller extends AsyncTask<Void, Void, Integer> {
- Preference installTools;
+ private static final class ToolsInstaller extends AsyncTask<Void, Void, Integer> {
+ private static final String[][] LIBRARY_NAMED_EXECUTABLES = {
+ {"libwg.so", "wg"},
+ {"libwg-quick.so", "wg-quick"}
+ };
- public ToolsInstaller(Preference installTools) {
- this.installTools = installTools;
- installTools.setEnabled(false);
- installTools.setSummary(installTools.getContext().getString(R.string.install_cmd_line_tools_progress));
- }
+ private final Context context;
+ private final Preference preference;
- private static final String[][] libraryNamedExecutables = {
- { "libwg.so", "wg" },
- { "libwg-quick.so", "wg-quick" }
- };
+ private ToolsInstaller(final Preference preference) {
+ context = preference.getContext();
+ this.preference = preference;
+ preference.setEnabled(false);
+ preference.setSummary(context.getString(R.string.install_cmd_line_tools_progress));
+ }
@Override
protected Integer doInBackground(final Void... voids) {
- final Context context = installTools.getContext();
final String libDir = context.getApplicationInfo().nativeLibraryDir;
final StringBuilder cmd = new StringBuilder();
cmd.append("set -ex;");
- for (final String[] libraryNamedExecutable : libraryNamedExecutables) {
- final String arg1 = "'" + libDir + "/" + libraryNamedExecutable[0] + "'";
- final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + "'";
+ for (final String[] libraryNamedExecutable : LIBRARY_NAMED_EXECUTABLES) {
+ final String arg1 = '\'' + libDir + '/' + libraryNamedExecutable[0] + '\'';
+ final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + '\'';
cmd.append(String.format("cmp -s %s %s && ", arg1, arg2));
}
@@ -71,9 +80,9 @@ public class SettingsActivity extends Activity {
cmd.append("trap 'mount -o ro,remount /system' EXIT;");
cmd.append("mount -o rw,remount /system;");
- for (final String[] libraryNamedExecutable : libraryNamedExecutables) {
- final String arg1 = "'" + libDir + "/" + libraryNamedExecutable[0] + "'";
- final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + "'";
+ for (final String[] libraryNamedExecutable : LIBRARY_NAMED_EXECUTABLES) {
+ final String arg1 = '\'' + libDir + '/' + libraryNamedExecutable[0] + '\'';
+ final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + '\'';
cmd.append(String.format("cp %s %s; chmod 755 %s;", arg1, arg2, arg2));
}
@@ -82,8 +91,7 @@ public class SettingsActivity extends Activity {
@Override
protected void onPostExecute(final Integer ret) {
- final Context context = installTools.getContext();
- String status;
+ final String status;
switch (ret) {
case 0:
@@ -96,8 +104,8 @@ public class SettingsActivity extends Activity {
status = context.getString(R.string.install_cmd_line_tools_failure);
break;
}
- installTools.setSummary(status);
- installTools.setEnabled(true);
+ preference.setSummary(status);
+ preference.setEnabled(true);
}
}
}
diff --git a/app/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java b/app/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java
new file mode 100644
index 00000000..2e0454ee
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.java
@@ -0,0 +1,28 @@
+package com.wireguard.android.activity;
+
+import android.os.Bundle;
+
+import com.wireguard.android.fragment.ConfigEditorFragment;
+import com.wireguard.android.model.Tunnel;
+
+/**
+ * Created by samuel on 12/29/17.
+ */
+
+public class TunnelCreatorActivity extends BaseActivity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getFragmentManager().findFragmentById(android.R.id.content) == null) {
+ getFragmentManager().beginTransaction()
+ .add(android.R.id.content, new ConfigEditorFragment())
+ .commit();
+ }
+ }
+
+ @Override
+ protected Tunnel onSelectedTunnelChanged(final Tunnel oldTunnel, final Tunnel newTunnel) {
+ finish();
+ return null;
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/backend/Backend.java b/app/src/main/java/com/wireguard/android/backend/Backend.java
new file mode 100644
index 00000000..c44f626a
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/backend/Backend.java
@@ -0,0 +1,57 @@
+package com.wireguard.android.backend;
+
+import com.wireguard.android.model.Tunnel;
+import com.wireguard.android.model.Tunnel.State;
+import com.wireguard.android.model.Tunnel.Statistics;
+import com.wireguard.config.Config;
+
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * Interface for implementations of the WireGuard secure network tunnel.
+ */
+
+public interface Backend {
+ /**
+ * Update the volatile configuration of a running tunnel, asynchronously, and return the
+ * resulting configuration. If the tunnel is not up, return the configuration that would result
+ * (if known), or else simply return the given configuration.
+ *
+ * @param tunnel The tunnel to apply the configuration to.
+ * @param config The new configuration for this tunnel.
+ * @return A future completed when the configuration of the tunnel has been updated, and the new
+ * volatile configuration has been determined. This future will always be completed on the main
+ * thread.
+ */
+ CompletionStage<Config> applyConfig(Tunnel tunnel, Config config);
+
+ /**
+ * Get the actual state of a tunnel, asynchronously.
+ *
+ * @param tunnel The tunnel to examine the state of.
+ * @return A future completed when the state of the tunnel has been determined. This future will
+ * always be completed on the main thread.
+ */
+ CompletionStage<State> getState(Tunnel tunnel);
+
+ /**
+ * Get statistics about traffic and errors on this tunnel, asynchronously. If the tunnel is not
+ * running, the statistics object will be filled with zero values.
+ *
+ * @param tunnel The tunnel to retrieve statistics for.
+ * @return A future completed when statistics for the tunnel are available. This future will
+ * always be completed on the main thread.
+ */
+ CompletionStage<Statistics> getStatistics(Tunnel tunnel);
+
+ /**
+ * Set the state of a tunnel, asynchronously.
+ *
+ * @param tunnel The tunnel to control the state of.
+ * @param state The new state for this tunnel. Must be {@code UP}, {@code DOWN}, or
+ * {@code TOGGLE}.
+ * @return A future completed when the state of the tunnel has changed, containing the new state
+ * of the tunnel. This future will always be completed on the main thread.
+ */
+ CompletionStage<State> setState(Tunnel tunnel, State state);
+}
diff --git a/app/src/main/java/com/wireguard/android/backend/WgQuickBackend.java b/app/src/main/java/com/wireguard/android/backend/WgQuickBackend.java
new file mode 100644
index 00000000..af6bc771
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/backend/WgQuickBackend.java
@@ -0,0 +1,94 @@
+package com.wireguard.android.backend;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.wireguard.android.model.Tunnel;
+import com.wireguard.android.model.Tunnel.State;
+import com.wireguard.android.model.Tunnel.Statistics;
+import com.wireguard.android.util.AsyncWorker;
+import com.wireguard.android.util.RootShell;
+import com.wireguard.config.Config;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * Created by samuel on 12/19/17.
+ */
+
+public final class WgQuickBackend implements Backend {
+ private static final String TAG = WgQuickBackend.class.getSimpleName();
+
+ private final AsyncWorker asyncWorker;
+ private final Context context;
+ private final RootShell rootShell;
+
+ public WgQuickBackend(final AsyncWorker asyncWorker, final Context context,
+ final RootShell rootShell) {
+ this.asyncWorker = asyncWorker;
+ this.context = context;
+ this.rootShell = rootShell;
+ }
+
+ private static State resolveState(final State currentState, State requestedState) {
+ if (requestedState == State.UNKNOWN)
+ throw new IllegalArgumentException("Requested unknown state");
+ if (requestedState == State.TOGGLE)
+ requestedState = currentState == State.UP ? State.DOWN : State.UP;
+ return requestedState;
+ }
+
+ @Override
+ public CompletionStage<Config> applyConfig(final Tunnel tunnel, final Config config) {
+ if (tunnel.getState() == State.UP)
+ return CompletableFuture.failedFuture(new UnsupportedOperationException("stub"));
+ return CompletableFuture.completedFuture(config);
+ }
+
+ @Override
+ public CompletionStage<State> getState(final Tunnel tunnel) {
+ Log.v(TAG, "Requested state for tunnel " + tunnel.getName());
+ return asyncWorker.supplyAsync(() -> {
+ final List<String> output = new LinkedList<>();
+ final State state;
+ if (rootShell.run(output, "wg show interfaces") != 0) {
+ state = State.UNKNOWN;
+ } else if (output.isEmpty()) {
+ // There are no running interfaces.
+ state = State.DOWN;
+ } else {
+ // wg puts all interface names on the same line. Split them into separate elements.
+ final String[] names = output.get(0).split(" ");
+ state = Arrays.asList(names).contains(tunnel.getName()) ? State.UP : State.DOWN;
+ }
+ Log.v(TAG, "Got state " + state + " for tunnel " + tunnel.getName());
+ return state;
+ });
+ }
+
+ @Override
+ public CompletionStage<Statistics> getStatistics(final Tunnel tunnel) {
+ return CompletableFuture.completedFuture(new Statistics());
+ }
+
+ @Override
+ public CompletionStage<State> setState(final Tunnel tunnel, final State state) {
+ Log.v(TAG, "Requested state change to " + state + " for tunnel " + tunnel.getName());
+ return tunnel.getStateAsync().thenCompose(currentState -> asyncWorker.supplyAsync(() -> {
+ final String stateName = resolveState(currentState, state).name().toLowerCase();
+ final File file = new File(context.getFilesDir(), tunnel.getName() + ".conf");
+ final String path = file.getAbsolutePath();
+ // FIXME: Assumes file layout from FIleConfigStore. Use a temporary file.
+ if (rootShell.run(null, String.format("wg-quick %s '%s'", stateName, path)) != 0)
+ throw new IOException("wg-quick failed");
+ return tunnel;
+ })).thenCompose(this::getState);
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/backends/VpnService.java b/app/src/main/java/com/wireguard/android/backends/VpnService.java
deleted file mode 100644
index 5002a835..00000000
--- a/app/src/main/java/com/wireguard/android/backends/VpnService.java
+++ /dev/null
@@ -1,559 +0,0 @@
-package com.wireguard.android.backends;
-
-import android.app.Service;
-import android.content.ComponentName;
-import android.content.ContentResolver;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.database.Cursor;
-import android.net.Uri;
-import android.os.AsyncTask;
-import android.os.Binder;
-import android.os.Build;
-import android.os.IBinder;
-import android.preference.PreferenceManager;
-import android.provider.OpenableColumns;
-import android.service.quicksettings.TileService;
-import android.system.OsConstants;
-import android.util.Log;
-import android.widget.Toast;
-
-import com.wireguard.android.NotSupportedActivity;
-import com.wireguard.android.QuickTileService;
-import com.wireguard.android.R;
-import com.wireguard.android.databinding.ObservableSortedMap;
-import com.wireguard.android.databinding.ObservableTreeMap;
-import com.wireguard.config.Config;
-import com.wireguard.config.Peer;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.FilenameFilter;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Service that handles config state coordination and all background processing for the application.
- */
-
-public class VpnService extends Service
- implements SharedPreferences.OnSharedPreferenceChangeListener {
- public static final String KEY_ENABLED_CONFIGS = "enabled_configs";
- public static final String KEY_PRIMARY_CONFIG = "primary_config";
- public static final String KEY_RESTORE_ON_BOOT = "restore_on_boot";
- private static final String TAG = "WireGuard/VpnService";
-
- private static VpnService instance;
- private final IBinder binder = new Binder();
- private final ObservableTreeMap<String, Config> configurations = new ObservableTreeMap<>();
- private final Set<String> enabledConfigs = new HashSet<>();
- private SharedPreferences preferences;
- private String primaryName;
- private RootShell rootShell;
-
- public static VpnService getInstance() {
- return instance;
- }
-
- /**
- * Add a new configuration to the set of known configurations. The configuration will initially
- * be disabled. The configuration's name must be unique within the set of known configurations.
- *
- * @param config The configuration to add.
- */
- public void add(final Config config) {
- new ConfigUpdater(null, config, false).execute();
- }
-
- /**
- * Attempt to disable and tear down an interface for this configuration. The configuration's
- * enabled state will be updated the operation is successful. If this configuration is already
- * disconnected, or it is not a known configuration, no changes will be made.
- *
- * @param name The name of the configuration (in the set of known configurations) to disable.
- */
- public void disable(final String name) {
- final Config config = configurations.get(name);
- if (config == null || !config.isEnabled())
- return;
- new ConfigDisabler(config).execute();
- }
-
- /**
- * Attempt to set up and enable an interface for this configuration. The configuration's enabled
- * state will be updated if the operation is successful. If this configuration is already
- * enabled, or it is not a known configuration, no changes will be made.
- *
- * @param name The name of the configuration (in the set of known configurations) to enable.
- */
- public void enable(final String name) {
- final Config config = configurations.get(name);
- if (config == null || config.isEnabled())
- return;
- new ConfigEnabler(config).execute();
- }
-
- /**
- * Retrieve a configuration known and managed by this service. The returned object must not be
- * modified directly.
- *
- * @param name The name of the configuration (in the set of known configurations) to retrieve.
- * @return An object representing the configuration. This object must not be modified.
- */
- public Config get(final String name) {
- return configurations.get(name);
- }
-
- /**
- * Retrieve the set of configurations known and managed by the service. Configurations in this
- * set must not be modified directly. If a configuration is to be updated, first create a copy
- * of it by calling getCopy().
- *
- * @return The set of known configurations.
- */
- public ObservableSortedMap<String, Config> getConfigs() {
- return configurations;
- }
-
- public void importFrom(final Uri... uris) {
- new ConfigImporter().execute(uris);
- }
-
- @Override
- public IBinder onBind(final Intent intent) {
- instance = this;
- return binder;
- }
-
- @Override
- public void onCreate() {
- // Ensure the service sticks around after being unbound. This only needs to happen once.
- startService(new Intent(this, getClass()));
- rootShell = new RootShell(this);
- new ConfigLoader().execute(getFilesDir().listFiles(new FilenameFilter() {
- @Override
- public boolean accept(final File dir, final String name) {
- return name.endsWith(".conf");
- }
- }));
- preferences = PreferenceManager.getDefaultSharedPreferences(this);
- preferences.registerOnSharedPreferenceChangeListener(this);
- }
-
- @Override
- public void onDestroy() {
- preferences.unregisterOnSharedPreferenceChangeListener(this);
- }
-
- @Override
- public void onSharedPreferenceChanged(final SharedPreferences preferences,
- final String key) {
- if (!KEY_PRIMARY_CONFIG.equals(key))
- return;
- boolean changed = false;
- final String newName = preferences.getString(key, null);
- if (primaryName != null && !primaryName.equals(newName)) {
- final Config oldConfig = configurations.get(primaryName);
- if (oldConfig != null)
- oldConfig.setIsPrimary(false);
- changed = true;
- }
- if (newName != null && !newName.equals(primaryName)) {
- final Config newConfig = configurations.get(newName);
- if (newConfig != null)
- newConfig.setIsPrimary(true);
- else
- preferences.edit().remove(KEY_PRIMARY_CONFIG).apply();
- changed = true;
- }
- primaryName = newName;
- if (changed)
- updateTile();
- }
-
- @Override
- public int onStartCommand(final Intent intent, final int flags, final int startId) {
- instance = this;
- return START_STICKY;
- }
-
- /**
- * Remove a configuration from being managed by the service. If it is currently enabled, the
- * the configuration will be disabled before removal. If successful, the configuration will be
- * removed from persistent storage. If the configuration is not known to the service, no changes
- * will be made.
- *
- * @param name The name of the configuration (in the set of known configurations) to remove.
- */
- public void remove(final String name) {
- final Config config = configurations.get(name);
- if (config == null)
- return;
- if (config.isEnabled())
- new ConfigDisabler(config).execute();
- new ConfigRemover(config).execute();
- }
-
- /**
- * Update the attributes of the named configuration. If the configuration is currently enabled,
- * it will be disabled before the update, and the service will attempt to re-enable it
- * afterward. If successful, the updated configuration will be saved to persistent storage.
- *
- * @param name The name of an existing configuration to update.
- * @param config A copy of the configuration, with updated attributes.
- */
- public void update(final String name, final Config config) {
- if (name == null)
- return;
- if (configurations.containsValue(config))
- throw new IllegalArgumentException("Config " + config.getName() + " modified directly");
- final Config oldConfig = configurations.get(name);
- if (oldConfig == null)
- return;
- final boolean wasEnabled = oldConfig.isEnabled();
- if (wasEnabled)
- new ConfigDisabler(oldConfig).execute();
- new ConfigUpdater(oldConfig, config, wasEnabled).execute();
- }
-
- private void updateTile() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
- return;
- Log.v(TAG, "Requesting quick tile update");
- TileService.requestListeningState(this, new ComponentName(this, QuickTileService.class));
- }
-
- private class ConfigDisabler extends AsyncTask<Void, Void, Boolean> {
- private final Config config;
-
- private ConfigDisabler(final Config config) {
- this.config = config;
- }
-
- @Override
- protected Boolean doInBackground(final Void... voids) {
- Log.i(TAG, "Running wg-quick down for " + config.getName());
- final File configFile = new File(getFilesDir(), config.getName() + ".conf");
- return rootShell.run(null, "wg-quick down '" + configFile.getPath() + "'") == 0;
- }
-
- @Override
- protected void onPostExecute(final Boolean result) {
- config.setIsEnabled(!result);
- if (!result) {
- Toast.makeText(getApplicationContext(), getString(R.string.error_down),
- Toast.LENGTH_SHORT).show();
- return;
- }
- enabledConfigs.remove(config.getName());
- preferences.edit().putStringSet(KEY_ENABLED_CONFIGS, enabledConfigs).apply();
- if (config.getName().equals(primaryName))
- updateTile();
- }
- }
-
- private class ConfigEnabler extends AsyncTask<Void, Void, Integer> {
- private final Config config;
-
- private ConfigEnabler(final Config config) {
- this.config = config;
- }
-
- @Override
- protected Integer doInBackground(final Void... voids) {
- if (!new File("/sys/module/wireguard").exists())
- return -0xfff0001;
- if (!existsInPath("su"))
- return -0xfff0002;
- Log.i(TAG, "Running wg-quick up for " + config.getName());
- final File configFile = new File(getFilesDir(), config.getName() + ".conf");
- final int ret = rootShell.run(null, "wg-quick up '" + configFile.getPath() + "'");
- if (ret == OsConstants.EACCES)
- return -0xfff0002;
- return ret;
- }
-
- private boolean existsInPath(final String file) {
- final String pathEnv = System.getenv("PATH");
- if (pathEnv == null)
- return false;
- final String[] paths = pathEnv.split(":");
- for (final String path : paths)
- if (new File(path, file).exists())
- return true;
- return false;
- }
-
- @Override
- protected void onPostExecute(final Integer ret) {
- config.setIsEnabled(ret == 0);
- if (ret != 0) {
- if (ret == -0xfff0001) {
- startActivity(new Intent(getApplicationContext(), NotSupportedActivity.class));
- } else if (ret == -0xfff0002) {
- Toast.makeText(getApplicationContext(), getString(R.string.error_su),
- Toast.LENGTH_LONG).show();
- } else {
- Toast.makeText(getApplicationContext(), getString(R.string.error_up),
- Toast.LENGTH_SHORT).show();
- }
- return;
- }
- enabledConfigs.add(config.getName());
- preferences.edit().putStringSet(KEY_ENABLED_CONFIGS, enabledConfigs).apply();
- if (config.getName().equals(primaryName))
- updateTile();
- }
- }
-
- private class ConfigImporter extends AsyncTask<Uri, String, List<File>> {
- @Override
- protected List<File> doInBackground(final Uri... uris) {
- final ContentResolver contentResolver = getContentResolver();
- final List<File> files = new ArrayList<>(uris.length);
- for (final Uri uri : uris) {
- if (isCancelled())
- return null;
- String name = null;
- if ("file".equals(uri.getScheme())) {
- name = uri.getLastPathSegment();
- } else {
- final String[] columns = {OpenableColumns.DISPLAY_NAME};
- try (final Cursor cursor =
- getContentResolver().query(uri, columns, null, null, null)) {
- if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
- name = cursor.getString(0);
- Log.v(getClass().getSimpleName(), "Got name via cursor");
- }
- }
- if (name == null) {
- name = Uri.decode(uri.getLastPathSegment());
- if (name.indexOf('/') >= 0)
- name = name.substring(name.lastIndexOf('/') + 1);
- Log.v(getClass().getSimpleName(), "Got name from urlencoded path");
- }
- }
- if (!name.endsWith(".conf"))
- name = name + ".conf";
- if (!Config.isNameValid(name.substring(0, name.length() - 5))) {
- Log.v(getClass().getSimpleName(), "Detected name is not valid: " + name);
- publishProgress(name + ": Invalid config filename");
- continue;
- }
- Log.d(getClass().getSimpleName(), "Mapped URI " + uri + " to file name " + name);
- final File output = new File(getFilesDir(), name);
- if (output.exists()) {
- Log.w(getClass().getSimpleName(), "Config file " + name + " already exists");
- publishProgress(name + " already exists");
- continue;
- }
- try (final InputStream in = contentResolver.openInputStream(uri);
- final OutputStream out = new FileOutputStream(output, false)) {
- if (in == null)
- throw new IOException("Failed to open input");
- // FIXME: This is a rather arbitrary size.
- final byte[] buffer = new byte[4096];
- int bytes;
- while ((bytes = in.read(buffer)) != -1)
- out.write(buffer, 0, bytes);
- files.add(output);
- } catch (final IOException e) {
- Log.w(getClass().getSimpleName(), "Failed to import config from " + uri, e);
- publishProgress(name + ": " + e.getMessage());
- }
- }
- return files;
- }
-
- @Override
- protected void onProgressUpdate(final String... errors) {
- Toast.makeText(getApplicationContext(), errors[0], Toast.LENGTH_SHORT).show();
- }
-
- @Override
- protected void onPostExecute(final List<File> files) {
- new ConfigLoader().execute(files.toArray(new File[files.size()]));
- }
- }
-
- private class ConfigLoader extends AsyncTask<File, String, List<Config>> {
- @Override
- protected List<Config> doInBackground(final File... files) {
- final List<Config> configs = new LinkedList<>();
- final List<String> interfaces = new LinkedList<>();
- final String command = "wg show interfaces";
- if (rootShell.run(interfaces, command) == 0 && interfaces.size() == 1) {
- // wg puts all interface names on the same line. Split them into separate elements.
- final String nameList = interfaces.get(0);
- Collections.addAll(interfaces, nameList.split(" "));
- interfaces.remove(0);
- } else {
- interfaces.clear();
- Log.w(TAG, "No existing WireGuard interfaces found. Maybe they are all disabled?");
- }
- for (final File file : files) {
- if (isCancelled())
- return null;
- final String fileName = file.getName();
- final String configName = fileName.substring(0, fileName.length() - 5);
- Log.v(TAG, "Attempting to load config " + configName);
- try {
- final Config config = new Config();
- config.parseFrom(openFileInput(fileName));
- config.setIsEnabled(interfaces.contains(configName));
- config.setName(configName);
- configs.add(config);
- } catch (IllegalArgumentException | IOException e) {
- if (!file.delete()) {
- Log.e(TAG, "Could not delete configuration for config " + configName);
- }
- Log.w(TAG, "Failed to load config from " + fileName, e);
- publishProgress(fileName + ": " + e.getMessage());
- }
- }
- return configs;
- }
-
- @Override
- protected void onProgressUpdate(final String... errors) {
- Toast.makeText(getApplicationContext(), errors[0], Toast.LENGTH_SHORT).show();
- }
-
- @Override
- protected void onPostExecute(final List<Config> configs) {
- if (configs == null)
- return;
- for (final Config config : configs)
- configurations.put(config.getName(), config);
- // Run the handler to avoid duplicating the code here.
- onSharedPreferenceChanged(preferences, KEY_PRIMARY_CONFIG);
- if (preferences.getBoolean(KEY_RESTORE_ON_BOOT, false)) {
- final Set<String> configsToEnable =
- preferences.getStringSet(KEY_ENABLED_CONFIGS, null);
- if (configsToEnable != null) {
- for (final String name : configsToEnable) {
- final Config config = configurations.get(name);
- if (config != null && !config.isEnabled())
- new ConfigEnabler(config).execute();
- }
- }
- }
- }
- }
-
- private class ConfigRemover extends AsyncTask<Void, Void, Boolean> {
- private final Config config;
-
- private ConfigRemover(final Config config) {
- this.config = config;
- }
-
- @Override
- protected Boolean doInBackground(final Void... voids) {
- Log.i(TAG, "Removing config " + config.getName());
- final File configFile = new File(getFilesDir(), config.getName() + ".conf");
- if (configFile.delete()) {
- return true;
- } else {
- Log.e(TAG, "Could not delete configuration for config " + config.getName());
- return false;
- }
- }
-
- @Override
- protected void onPostExecute(final Boolean result) {
- if (!result)
- return;
- configurations.remove(config.getName());
- if (config.getName().equals(primaryName)) {
- // This will get picked up by the preference change listener.
- preferences.edit().remove(KEY_PRIMARY_CONFIG).apply();
- }
- }
- }
-
- private class ConfigUpdater extends AsyncTask<Void, Void, Boolean> {
- private final Config newConfig;
- private final String newName;
- private final String oldName;
- private final Boolean shouldConnect;
- private Config knownConfig;
-
- private ConfigUpdater(final Config knownConfig, final Config newConfig,
- final Boolean shouldConnect) {
- this.knownConfig = knownConfig;
- this.newConfig = newConfig.copy();
- newName = newConfig.getName();
- // When adding a config, "old file" and "new file" are the same thing.
- oldName = knownConfig != null ? knownConfig.getName() : newName;
- this.shouldConnect = shouldConnect;
- if (newName == null || !Config.isNameValid(newName))
- throw new IllegalArgumentException("This configuration does not have a valid name");
- if (isAddOrRename() && configurations.containsKey(newName))
- throw new IllegalStateException("Configuration " + newName + " already exists");
- if (newConfig.getInterface().getPublicKey() == null)
- throw new IllegalArgumentException("This configuration needs a valid private key");
- for (final Peer peer : newConfig.getPeers())
- if (peer.getPublicKey() == null || peer.getPublicKey().isEmpty())
- throw new IllegalArgumentException("Each peer must have a valid public key");
- }
-
- @Override
- protected Boolean doInBackground(final Void... voids) {
- Log.i(TAG, (knownConfig == null ? "Adding" : "Updating") + " config " + newName);
- final File newFile = new File(getFilesDir(), newName + ".conf");
- final File oldFile = new File(getFilesDir(), oldName + ".conf");
- if (isAddOrRename() && newFile.exists()) {
- Log.w(TAG, "Refusing to overwrite existing config configuration");
- return false;
- }
- try {
- final FileOutputStream stream = openFileOutput(oldFile.getName(), MODE_PRIVATE);
- stream.write(newConfig.toString().getBytes(StandardCharsets.UTF_8));
- stream.close();
- } catch (final IOException e) {
- Log.e(TAG, "Could not save configuration for config " + oldName, e);
- return false;
- }
- if (isRename() && !oldFile.renameTo(newFile)) {
- Log.e(TAG, "Could not rename " + oldFile.getName() + " to " + newFile.getName());
- return false;
- }
- return true;
- }
-
- private boolean isAddOrRename() {
- return knownConfig == null || !newName.equals(oldName);
- }
-
- private boolean isRename() {
- return knownConfig != null && !newName.equals(oldName);
- }
-
- @Override
- protected void onPostExecute(final Boolean result) {
- if (!result)
- return;
- if (knownConfig != null)
- configurations.remove(oldName);
- if (knownConfig == null)
- knownConfig = new Config();
- knownConfig.copyFrom(newConfig);
- knownConfig.setIsEnabled(false);
- knownConfig.setIsPrimary(oldName != null && oldName.equals(primaryName));
- configurations.put(newName, knownConfig);
- if (isRename() && oldName != null && oldName.equals(primaryName))
- preferences.edit().putString(KEY_PRIMARY_CONFIG, newName).apply();
- if (shouldConnect)
- new ConfigEnabler(knownConfig).execute();
- }
- }
-}
diff --git a/app/src/main/java/com/wireguard/android/configStore/ConfigStore.java b/app/src/main/java/com/wireguard/android/configStore/ConfigStore.java
new file mode 100644
index 00000000..19bb6bf5
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/configStore/ConfigStore.java
@@ -0,0 +1,65 @@
+package com.wireguard.android.configStore;
+
+import com.wireguard.config.Config;
+
+import java.util.Set;
+
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * 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 A future completed when the tunnel and its configuration have been saved to
+ * persistent storage. This future encapsulates the configuration that was actually saved to
+ * persistent storage. This future will always be completed on the main thread.
+ */
+ CompletionStage<Config> create(final String name, final Config config);
+
+ /**
+ * Delete a persistent tunnel.
+ *
+ * @param name The name of the tunnel to delete.
+ * @return A future completed when the tunnel and its configuration have been deleted. This
+ * future will always be completed on the main thread.
+ */
+ CompletionStage<Void> delete(final String name);
+
+ /**
+ * Enumerate the names of tunnels present in persistent storage.
+ *
+ * @return A future completed when the set of present tunnel names is available. This future
+ * will always be completed on the main thread.
+ */
+ CompletionStage<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 A future completed when an in-memory representation of the configuration is
+ * available. This future encapsulates the configuration loaded from persistent storage. This
+ * future will always be completed on the main thread.
+ */
+ CompletionStage<Config> load(final String name);
+
+ /**
+ * 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 A future completed when the configuration has been saved to persistent storage. This
+ * future encapsulates the configuration that was actually saved to persistent storage. This
+ * future will always be completed on the main thread.
+ */
+ CompletionStage<Config> save(final String name, final Config config);
+}
diff --git a/app/src/main/java/com/wireguard/android/configStore/FileConfigStore.java b/app/src/main/java/com/wireguard/android/configStore/FileConfigStore.java
new file mode 100644
index 00000000..099bc0d3
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/configStore/FileConfigStore.java
@@ -0,0 +1,98 @@
+package com.wireguard.android.configStore;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.wireguard.android.Application.ApplicationContext;
+import com.wireguard.android.util.AsyncWorker;
+import com.wireguard.config.Config;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+
+import java9.util.concurrent.CompletionStage;
+import java9.util.stream.Collectors;
+import java9.util.stream.Stream;
+
+/**
+ * Created by samuel on 12/28/17.
+ */
+
+public final class FileConfigStore implements ConfigStore {
+ private static final String TAG = FileConfigStore.class.getSimpleName();
+
+ private final AsyncWorker asyncWorker;
+ private final Context context;
+
+ public FileConfigStore(final AsyncWorker asyncWorker,
+ @ApplicationContext final Context context) {
+ this.asyncWorker = asyncWorker;
+ this.context = context;
+ }
+
+ @Override
+ public CompletionStage<Config> create(final String name, final Config config) {
+ return asyncWorker.supplyAsync(() -> {
+ final File file = fileFor(name);
+ if (!file.createNewFile()) {
+ final String message = "Configuration file " + file.getName() + " already exists";
+ throw new IllegalStateException(message);
+ }
+ try (FileOutputStream stream = new FileOutputStream(file, false)) {
+ stream.write(config.toString().getBytes(StandardCharsets.UTF_8));
+ return config;
+ }
+ });
+ }
+
+ @Override
+ public CompletionStage<Void> delete(final String name) {
+ return asyncWorker.runAsync(() -> {
+ final File file = fileFor(name);
+ if (!file.delete())
+ throw new IOException("Cannot delete configuration file " + file.getName());
+ });
+ }
+
+ @Override
+ public CompletionStage<Set<String>> enumerate() {
+ return asyncWorker.supplyAsync(() -> 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 CompletionStage<Config> load(final String name) {
+ return asyncWorker.supplyAsync(() -> {
+ try (FileInputStream stream = new FileInputStream(fileFor(name))) {
+ return Config.from(stream);
+ }
+ });
+ }
+
+ @Override
+ public CompletionStage<Config> save(final String name, final Config config) {
+ Log.d(TAG, "Requested save config for tunnel " + name);
+ return asyncWorker.supplyAsync(() -> {
+ final File file = fileFor(name);
+ if (!file.isFile()) {
+ final String message = "Configuration file " + file.getName() + " not found";
+ throw new IllegalStateException(message);
+ }
+ try (FileOutputStream stream = new FileOutputStream(file, false)) {
+ Log.d(TAG, "Writing out config for tunnel " + name);
+ stream.write(config.toString().getBytes(StandardCharsets.UTF_8));
+ return config;
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/databinding/BindingAdapters.java b/app/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
index 5dfc2d19..072a9fdc 100644
--- a/app/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
+++ b/app/src/main/java/com/wireguard/android/databinding/BindingAdapters.java
@@ -12,13 +12,22 @@ import android.widget.TextView;
import com.wireguard.android.R;
import com.wireguard.android.widget.ToggleSwitch;
+import org.threeten.bp.Instant;
+import org.threeten.bp.ZoneId;
+import org.threeten.bp.ZonedDateTime;
+import org.threeten.bp.format.DateTimeFormatter;
+
/**
* Static methods for use by generated code in the Android data binding library.
*/
-@SuppressWarnings("unused")
+@SuppressWarnings({"unused", "WeakerAccess"})
public final class BindingAdapters {
- @BindingAdapter({"app:checked"})
+ private BindingAdapters() {
+ // Prevent instantiation.
+ }
+
+ @BindingAdapter({"checked"})
public static void setChecked(final ToggleSwitch view, final boolean checked) {
view.setCheckedInternal(checked);
}
@@ -80,9 +89,9 @@ public final class BindingAdapters {
@BindingAdapter({"items", "layout"})
public static <K extends Comparable<K>, V> void setItems(final ListView view,
- final ObservableSortedMap<K, V> oldMap,
+ final ObservableNavigableMap<K, V> oldMap,
final int oldLayoutId,
- final ObservableSortedMap<K, V> newMap,
+ final ObservableNavigableMap<K, V> newMap,
final int newLayoutId) {
if (oldMap == newMap && oldLayoutId == newLayoutId)
return;
@@ -105,19 +114,26 @@ public final class BindingAdapters {
adapter.setMap(newMap);
}
- @BindingAdapter({"app:onBeforeCheckedChanged"})
+ @BindingAdapter({"onBeforeCheckedChanged"})
public static void setOnBeforeCheckedChanged(final ToggleSwitch view,
final ToggleSwitch.OnBeforeCheckedChangeListener
listener) {
view.setOnBeforeCheckedChangeListener(listener);
}
+ @BindingAdapter({"android:text"})
+ public static void setText(final TextView view, final Instant instant) {
+ if (instant == null || Instant.EPOCH.equals(instant)) {
+ view.setText(R.string.never);
+ } else {
+ final ZoneId defaultZone = ZoneId.systemDefault();
+ final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, defaultZone);
+ view.setText(zonedDateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME));
+ }
+ }
+
@BindingAdapter({"android:textStyle"})
public static void setTextStyle(final TextView view, final Typeface typeface) {
view.setTypeface(typeface);
}
-
- private BindingAdapters() {
- // Prevent instantiation.
- }
}
diff --git a/app/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java b/app/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
index 6d8debf2..2b693c9f 100644
--- a/app/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
+++ b/app/src/main/java/com/wireguard/android/databinding/ItemChangeListener.java
@@ -32,6 +32,7 @@ class ItemChangeListener<T> {
ViewDataBinding binding = DataBindingUtil.getBinding(convertView);
if (binding == null)
binding = DataBindingUtil.inflate(layoutInflater, layoutId, container, false);
+ binding.setVariable(BR.collection, list);
binding.setVariable(BR.item, list.get(position));
binding.executePendingBindings();
return binding.getRoot();
@@ -49,7 +50,7 @@ class ItemChangeListener<T> {
}
}
- private static class OnListChangedCallback<T>
+ private static final class OnListChangedCallback<T>
extends ObservableList.OnListChangedCallback<ObservableList<T>> {
private final WeakReference<ItemChangeListener<T>> weakListener;
diff --git a/app/src/main/java/com/wireguard/android/databinding/ObservableListAdapter.java b/app/src/main/java/com/wireguard/android/databinding/ObservableListAdapter.java
index 5727900d..5c4e8c0a 100644
--- a/app/src/main/java/com/wireguard/android/databinding/ObservableListAdapter.java
+++ b/app/src/main/java/com/wireguard/android/databinding/ObservableListAdapter.java
@@ -8,7 +8,6 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
-import android.widget.ListAdapter;
import com.wireguard.android.BR;
@@ -18,7 +17,7 @@ import java.lang.ref.WeakReference;
* A generic ListAdapter backed by an ObservableList.
*/
-class ObservableListAdapter<T> extends BaseAdapter implements ListAdapter {
+class ObservableListAdapter<T> extends BaseAdapter {
private final OnListChangedCallback<T> callback = new OnListChangedCallback<>(this);
private final int layoutId;
private final LayoutInflater layoutInflater;
@@ -54,6 +53,7 @@ class ObservableListAdapter<T> extends BaseAdapter implements ListAdapter {
ViewDataBinding binding = DataBindingUtil.getBinding(convertView);
if (binding == null)
binding = DataBindingUtil.inflate(layoutInflater, layoutId, parent, false);
+ binding.setVariable(BR.collection, list);
binding.setVariable(BR.item, getItem(position));
binding.executePendingBindings();
return binding.getRoot();
@@ -74,7 +74,7 @@ class ObservableListAdapter<T> extends BaseAdapter implements ListAdapter {
notifyDataSetChanged();
}
- private static class OnListChangedCallback<U>
+ private static final class OnListChangedCallback<U>
extends ObservableList.OnListChangedCallback<ObservableList<U>> {
private final WeakReference<ObservableListAdapter<U>> weakAdapter;
diff --git a/app/src/main/java/com/wireguard/android/databinding/ObservableMapAdapter.java b/app/src/main/java/com/wireguard/android/databinding/ObservableMapAdapter.java
index 70180728..4f63d6dd 100644
--- a/app/src/main/java/com/wireguard/android/databinding/ObservableMapAdapter.java
+++ b/app/src/main/java/com/wireguard/android/databinding/ObservableMapAdapter.java
@@ -8,28 +8,27 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
-import android.widget.ListAdapter;
import com.wireguard.android.BR;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.List;
/**
* A generic ListAdapter backed by a TreeMap that adds observability.
*/
-public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapter
- implements ListAdapter {
+public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapter {
private final OnMapChangedCallback<K, V> callback = new OnMapChangedCallback<>(this);
- private ArrayList<K> keys;
private final int layoutId;
private final LayoutInflater layoutInflater;
- private ObservableSortedMap<K, V> map;
+ private List<K> keys;
+ private ObservableNavigableMap<K, V> map;
ObservableMapAdapter(final Context context, final int layoutId,
- final ObservableSortedMap<K, V> map) {
+ final ObservableNavigableMap<K, V> map) {
this.layoutId = layoutId;
layoutInflater = LayoutInflater.from(context);
setMap(map);
@@ -51,14 +50,17 @@ public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapte
public long getItemId(final int position) {
if (map == null || position < 0 || position >= map.size())
return -1;
- return getItem(position).hashCode();
+ //final V item = getItem(position);
+ //return item != null ? item.hashCode() : -1;
+ final K key = getKey(position);
+ return key.hashCode();
}
private K getKey(final int position) {
return getKeys().get(position);
}
- private ArrayList<K> getKeys() {
+ private List<K> getKeys() {
if (keys == null)
keys = new ArrayList<>(map.keySet());
return keys;
@@ -75,6 +77,7 @@ public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapte
ViewDataBinding binding = DataBindingUtil.getBinding(convertView);
if (binding == null)
binding = DataBindingUtil.inflate(layoutInflater, layoutId, parent, false);
+ binding.setVariable(BR.collection, map);
binding.setVariable(BR.key, getKey(position));
binding.setVariable(BR.item, getItem(position));
binding.executePendingBindings();
@@ -86,7 +89,7 @@ public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapte
return true;
}
- void setMap(final ObservableSortedMap<K, V> newMap) {
+ void setMap(final ObservableNavigableMap<K, V> newMap) {
if (map != null)
map.removeOnMapChangedCallback(callback);
keys = null;
@@ -97,8 +100,8 @@ public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapte
notifyDataSetChanged();
}
- private static class OnMapChangedCallback<K extends Comparable<K>, V>
- extends ObservableMap.OnMapChangedCallback<ObservableSortedMap<K, V>, K, V> {
+ private static final class OnMapChangedCallback<K extends Comparable<K>, V>
+ extends ObservableMap.OnMapChangedCallback<ObservableNavigableMap<K, V>, K, V> {
private final WeakReference<ObservableMapAdapter<K, V>> weakAdapter;
@@ -107,7 +110,7 @@ public class ObservableMapAdapter<K extends Comparable<K>, V> extends BaseAdapte
}
@Override
- public void onMapChanged(final ObservableSortedMap<K, V> sender, final K key) {
+ public void onMapChanged(final ObservableNavigableMap<K, V> sender, final K key) {
final ObservableMapAdapter<K, V> adapter = weakAdapter.get();
if (adapter != null) {
adapter.keys = null;
diff --git a/app/src/main/java/com/wireguard/android/databinding/ObservableSortedMap.java b/app/src/main/java/com/wireguard/android/databinding/ObservableNavigableMap.java
index 1cbfeb6b..ab8a4a1f 100644
--- a/app/src/main/java/com/wireguard/android/databinding/ObservableSortedMap.java
+++ b/app/src/main/java/com/wireguard/android/databinding/ObservableNavigableMap.java
@@ -2,12 +2,12 @@ package com.wireguard.android.databinding;
import android.databinding.ObservableMap;
-import java.util.SortedMap;
+import java.util.NavigableMap;
/**
* Interface for maps that are both observable and sorted.
*/
-public interface ObservableSortedMap<K, V> extends ObservableMap<K, V>, SortedMap<K, V> {
+public interface ObservableNavigableMap<K, V> extends NavigableMap<K, V>, ObservableMap<K, V> {
// No additional methods.
}
diff --git a/app/src/main/java/com/wireguard/android/databinding/ObservableTreeMap.java b/app/src/main/java/com/wireguard/android/databinding/ObservableTreeMap.java
index dc5f705b..074e122c 100644
--- a/app/src/main/java/com/wireguard/android/databinding/ObservableTreeMap.java
+++ b/app/src/main/java/com/wireguard/android/databinding/ObservableTreeMap.java
@@ -12,16 +12,10 @@ import java.util.TreeMap;
* views. This behavior is in line with that of ObservableArrayMap.
*/
-public class ObservableTreeMap<K, V> extends TreeMap<K, V> implements ObservableSortedMap<K, V> {
+public class ObservableTreeMap<K, V> extends TreeMap<K, V> implements ObservableNavigableMap<K, V> {
private transient MapChangeRegistry listeners;
@Override
- public void clear() {
- super.clear();
- notifyChange(null);
- }
-
- @Override
public void addOnMapChangedCallback(
final OnMapChangedCallback<? extends ObservableMap<K, V>, K, V> listener) {
if (listeners == null)
@@ -29,6 +23,12 @@ public class ObservableTreeMap<K, V> extends TreeMap<K, V> implements Observable
listeners.add(listener);
}
+ @Override
+ public void clear() {
+ super.clear();
+ notifyChange(null);
+ }
+
private void notifyChange(final K key) {
if (listeners != null)
listeners.notifyChange(this, key);
@@ -51,8 +51,7 @@ public class ObservableTreeMap<K, V> extends TreeMap<K, V> implements Observable
@Override
public V remove(final Object key) {
final V oldValue = super.remove(key);
- @SuppressWarnings("unchecked")
- final K k = (K) key;
+ @SuppressWarnings("unchecked") final K k = (K) key;
notifyChange(k);
return oldValue;
}
diff --git a/app/src/main/java/com/wireguard/android/fragment/BaseFragment.java b/app/src/main/java/com/wireguard/android/fragment/BaseFragment.java
new file mode 100644
index 00000000..9e188bc0
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/fragment/BaseFragment.java
@@ -0,0 +1,45 @@
+package com.wireguard.android.fragment;
+
+import android.app.Fragment;
+import android.content.Context;
+
+import com.wireguard.android.activity.BaseActivity;
+import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener;
+import com.wireguard.android.model.Tunnel;
+
+/**
+ * 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 BaseActivity activity;
+
+ protected Tunnel getSelectedTunnel() {
+ return activity != null ? activity.getSelectedTunnel() : 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(final Tunnel tunnel) {
+ if (activity != null)
+ activity.setSelectedTunnel(tunnel);
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/fragment/ConfigEditorFragment.java b/app/src/main/java/com/wireguard/android/fragment/ConfigEditorFragment.java
new file mode 100644
index 00000000..e2f5aa02
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/fragment/ConfigEditorFragment.java
@@ -0,0 +1,205 @@
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import android.databinding.ObservableField;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+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 com.commonsware.cwac.crossport.design.widget.CoordinatorLayout;
+import com.commonsware.cwac.crossport.design.widget.Snackbar;
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.ConfigEditorFragmentBinding;
+import com.wireguard.android.model.Tunnel;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.config.Config;
+
+/**
+ * Fragment for editing a WireGuard configuration.
+ */
+
+public class ConfigEditorFragment extends BaseFragment {
+ private static final String KEY_LOCAL_CONFIG = "local_config";
+ private static final String KEY_LOCAL_NAME = "local_name";
+ private static final String TAG = ConfigEditorFragment.class.getSimpleName();
+
+ private final ObservableField<String> localName = new ObservableField<>();
+ private ConfigEditorFragmentBinding binding;
+ private boolean isViewStateRestored;
+ private Config localConfig = new Config();
+ private String originalName;
+
+ private static <T extends Parcelable> T copyParcelable(final T original) {
+ if (original == null)
+ return null;
+ final Parcel parcel = Parcel.obtain();
+ parcel.writeParcelable(original, 0);
+ parcel.setDataPosition(0);
+ final T copy = parcel.readParcelable(original.getClass().getClassLoader());
+ parcel.recycle();
+ return copy;
+ }
+
+ private void onConfigCreated(final Tunnel tunnel, final Throwable throwable) {
+ if (throwable != null) {
+ Log.e(TAG, "Cannot create tunnel", throwable);
+ final String message = "Cannot create tunnel: "
+ + ExceptionLoggers.unwrap(throwable).getMessage();
+ if (binding != null) {
+ final CoordinatorLayout container = binding.mainContainer;
+ Snackbar.make(container, message, Snackbar.LENGTH_LONG).show();
+ }
+ } else {
+ Log.d(TAG, "Successfully created tunnel " + tunnel.getName());
+ onFinished(tunnel);
+ }
+ }
+
+ private void onConfigLoaded(final Config config) {
+ localConfig = copyParcelable(config);
+ if (binding != null && isViewStateRestored)
+ binding.setConfig(localConfig);
+ }
+
+ private void onConfigSaved(final Config config, final Throwable throwable) {
+ if (throwable != null) {
+ Log.e(TAG, "Cannot save configuration", throwable);
+ final String message = "Cannot save configuration: "
+ + ExceptionLoggers.unwrap(throwable).getMessage();
+ if (binding != null) {
+ final CoordinatorLayout container = binding.mainContainer;
+ Snackbar.make(container, message, Snackbar.LENGTH_LONG).show();
+ }
+ } else {
+ Log.d(TAG, "Successfully saved configuration for " + getSelectedTunnel().getName());
+ onFinished(getSelectedTunnel());
+ }
+ }
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ localConfig = savedInstanceState.getParcelable(KEY_LOCAL_CONFIG);
+ localName.set(savedInstanceState.getString(KEY_LOCAL_NAME));
+ originalName = savedInstanceState.getString(TunnelManager.KEY_SELECTED_TUNNEL);
+ }
+ // Erase the remains of creating or editing a different tunnel.
+ if (getSelectedTunnel() != null && !getSelectedTunnel().getName().equals(originalName)) {
+ // The config must be loaded asynchronously since it's not an observable property.
+ localConfig = null;
+ getSelectedTunnel().getConfigAsync().thenAccept(this::onConfigLoaded);
+ originalName = getSelectedTunnel().getName();
+ localName.set(originalName);
+ } else if (getSelectedTunnel() == null && originalName != null) {
+ localConfig = new Config();
+ originalName = null;
+ localName.set(null);
+ }
+ 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, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = ConfigEditorFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ private void onFinished(final Tunnel tunnel) {
+ // Hide the keyboard; it rarely goes away on its own.
+ final Activity activity = getActivity();
+ 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(() -> {
+ setSelectedTunnel(null);
+ setSelectedTunnel(tunnel);
+ });
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_save:
+ if (getSelectedTunnel() != null) {
+ Log.d(TAG, "Attempting to save config to " + getSelectedTunnel().getName());
+ getSelectedTunnel().setConfig(localConfig)
+ .whenComplete(this::onConfigSaved);
+ } else {
+ Log.d(TAG, "Attempting to create new tunnel " + localName.get());
+ final TunnelManager manager = Application.getComponent().getTunnelManager();
+ manager.create(localName.get(), localConfig)
+ .whenComplete(this::onConfigCreated);
+ }
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ outState.putParcelable(KEY_LOCAL_CONFIG, localConfig);
+ outState.putString(KEY_LOCAL_NAME, localName.get());
+ outState.putString(TunnelManager.KEY_SELECTED_TUNNEL, originalName);
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(final Tunnel oldTunnel, final Tunnel newTunnel) {
+ // Erase the remains of creating or editing a different tunnel.
+ if (newTunnel != null) {
+ // The config must be loaded asynchronously since it's not an observable property.
+ localConfig = null;
+ newTunnel.getConfigAsync().thenAccept(this::onConfigLoaded);
+ originalName = newTunnel.getName();
+ } else {
+ localConfig = new Config();
+ if (binding != null && isViewStateRestored)
+ binding.setConfig(localConfig);
+ originalName = null;
+ }
+ localName.set(originalName);
+ }
+
+ @Override
+ public void onViewStateRestored(final Bundle savedInstanceState) {
+ super.onViewStateRestored(savedInstanceState);
+ binding.setConfig(localConfig);
+ binding.setName(localName);
+ // FIXME: Remove this when renaming works.
+ binding.interfaceNameText.setEnabled(originalName == null);
+ isViewStateRestored = true;
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java b/app/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
new file mode 100644
index 00000000..e236da71
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
@@ -0,0 +1,60 @@
+package com.wireguard.android.fragment;
+
+import android.os.Bundle;
+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.model.Tunnel;
+
+/**
+ * Fragment that shows details about a specific tunnel.
+ */
+
+public class TunnelDetailFragment extends BaseFragment {
+ private TunnelDetailFragmentBinding binding;
+ private boolean isViewStateRestored;
+
+ @Override
+ public void onCreate(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 View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelDetailFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(final Tunnel oldTunnel, final Tunnel newTunnel) {
+ if (binding != null && isViewStateRestored)
+ binding.setTunnel(newTunnel);
+ }
+
+ @Override
+ public void onViewStateRestored(final Bundle savedInstanceState) {
+ super.onViewStateRestored(savedInstanceState);
+ binding.setTunnel(getSelectedTunnel());
+ isViewStateRestored = true;
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java b/app/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java
new file mode 100644
index 00000000..ed14a82f
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java
@@ -0,0 +1,270 @@
+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 android.util.Log;
+import android.util.SparseBooleanArray;
+import android.view.ActionMode;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnTouchListener;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.AbsListView.MultiChoiceModeListener;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.AdapterView.OnItemLongClickListener;
+
+import com.commonsware.cwac.crossport.design.widget.CoordinatorLayout;
+import com.commonsware.cwac.crossport.design.widget.Snackbar;
+import com.wireguard.android.Application;
+import com.wireguard.android.Application.ApplicationComponent;
+import com.wireguard.android.R;
+import com.wireguard.android.activity.TunnelCreatorActivity;
+import com.wireguard.android.databinding.TunnelListFragmentBinding;
+import com.wireguard.android.model.Tunnel;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.util.AsyncWorker;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.config.Config;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+import java9.util.function.Function;
+import java9.util.stream.IntStream;
+
+/**
+ * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels.
+ */
+
+public class TunnelListFragment extends BaseFragment {
+ private static final int REQUEST_IMPORT = 1;
+ private static final String TAG = TunnelListFragment.class.getSimpleName();
+
+ private final MultiChoiceModeListener actionModeListener = new ActionModeListener();
+ private final ListViewCallbacks listViewCallbacks = new ListViewCallbacks();
+ private ActionMode actionMode;
+ private AsyncWorker asyncWorker;
+ private TunnelListFragmentBinding binding;
+ private TunnelManager tunnelManager;
+
+ private void importTunnel(final Uri uri) {
+ final Activity activity = getActivity();
+ if (activity == null)
+ return;
+ final ContentResolver contentResolver = activity.getContentResolver();
+ final CompletionStage<String> nameFuture = asyncWorker.supplyAsync(() -> {
+ final String[] columns = {OpenableColumns.DISPLAY_NAME};
+ String name = null;
+ try (final 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());
+ if (name.indexOf('/') >= 0)
+ name = name.substring(name.lastIndexOf('/') + 1);
+ if (name.endsWith(".conf"))
+ name = name.substring(0, name.length() - ".conf".length());
+ Log.d(TAG, "Import mapped URI " + uri + " to tunnel name " + name);
+ return name;
+ });
+ asyncWorker.supplyAsync(() -> Config.from(contentResolver.openInputStream(uri)))
+ .thenCombine(nameFuture, (config, name) -> tunnelManager.create(name, config))
+ .thenCompose(Function.identity())
+ .handle(this::onTunnelImportFinished);
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ switch (requestCode) {
+ case REQUEST_IMPORT:
+ if (resultCode == Activity.RESULT_OK)
+ importTunnel(data.getData());
+ return;
+ default:
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final ApplicationComponent applicationComponent = Application.getComponent();
+ asyncWorker = applicationComponent.getAsyncWorker();
+ tunnelManager = applicationComponent.getTunnelManager();
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelListFragmentBinding.inflate(inflater, container, false);
+ binding.tunnelList.setMultiChoiceModeListener(actionModeListener);
+ binding.tunnelList.setOnItemClickListener(listViewCallbacks);
+ binding.tunnelList.setOnItemLongClickListener(listViewCallbacks);
+ binding.tunnelList.setOnTouchListener(listViewCallbacks);
+ binding.executePendingBindings();
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ public void onRequestCreateConfig(@SuppressWarnings("unused") final View view) {
+ startActivity(new Intent(getActivity(), TunnelCreatorActivity.class));
+ binding.createMenu.collapse();
+ }
+
+ public void onRequestImportConfig(@SuppressWarnings("unused") final View view) {
+ final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("*/*");
+ startActivityForResult(intent, REQUEST_IMPORT);
+ binding.createMenu.collapse();
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(final Tunnel oldTunnel, final Tunnel newTunnel) {
+ // Do nothing.
+ }
+
+ private Void onTunnelDeletionFinished(final Integer count, final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = "Successfully deleted " + count + " tunnels";
+ } else {
+ message = "Could not delete some tunnels: "
+ + ExceptionLoggers.unwrap(throwable).getMessage();
+ Log.e(TAG, "Cannot delete tunnel", throwable);
+ }
+ if (binding != null) {
+ final CoordinatorLayout container = binding.mainContainer;
+ Snackbar.make(container, message, Snackbar.LENGTH_LONG).show();
+ }
+ return null;
+ }
+
+ private Void onTunnelImportFinished(final Tunnel tunnel, final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = "Successfully imported tunnel '" + tunnel.getName() + '\'';
+ } else {
+ message = "Cannot import tunnel: "
+ + ExceptionLoggers.unwrap(throwable).getMessage();
+ Log.e(TAG, "Cannot import tunnel", throwable);
+ }
+ if (binding != null) {
+ final CoordinatorLayout container = binding.mainContainer;
+ Snackbar.make(container, message, Snackbar.LENGTH_LONG).show();
+ }
+ return null;
+ }
+
+ @Override
+ public void onViewStateRestored(final Bundle savedInstanceState) {
+ super.onViewStateRestored(savedInstanceState);
+ binding.setFragment(this);
+ binding.setTunnels(tunnelManager.getTunnels());
+ }
+
+ private final class ActionModeListener implements MultiChoiceModeListener {
+ private Resources resources;
+ private AbsListView tunnelList;
+
+ private IntStream getCheckedPositions() {
+ final SparseBooleanArray checkedItemPositions = tunnelList.getCheckedItemPositions();
+ return IntStream.range(0, checkedItemPositions.size())
+ .filter(checkedItemPositions::valueAt)
+ .map(checkedItemPositions::keyAt);
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_delete:
+ final CompletableFuture[] futures = getCheckedPositions()
+ .mapToObj(pos -> (Tunnel) tunnelList.getItemAtPosition(pos))
+ .map(tunnelManager::delete)
+ .toArray(CompletableFuture[]::new);
+ CompletableFuture.allOf(futures)
+ .thenApply(x -> futures.length)
+ .handle(TunnelListFragment.this::onTunnelDeletionFinished);
+ mode.finish();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode mode, final Menu menu) {
+ actionMode = mode;
+ resources = getActivity().getResources();
+ tunnelList = binding.tunnelList;
+ mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu);
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode mode) {
+ actionMode = null;
+ resources = null;
+ }
+
+ @Override
+ public void onItemCheckedStateChanged(final ActionMode mode, final int position,
+ final long id, final boolean checked) {
+ updateTitle(mode);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) {
+ updateTitle(mode);
+ return false;
+ }
+
+ private void updateTitle(final ActionMode mode) {
+ final int count = (int) getCheckedPositions().count();
+ mode.setTitle(resources.getQuantityString(R.plurals.list_delete_title, count, count));
+ }
+ }
+
+ private final class ListViewCallbacks
+ implements OnItemClickListener, OnItemLongClickListener, OnTouchListener {
+ @Override
+ public void onItemClick(final AdapterView<?> parent, final View view,
+ final int position, final long id) {
+ setSelectedTunnel((Tunnel) parent.getItemAtPosition(position));
+ }
+
+ @Override
+ public boolean onItemLongClick(final AdapterView<?> parent, final View view,
+ final int position, final long id) {
+ if (actionMode != null)
+ return false;
+ binding.tunnelList.setItemChecked(position, true);
+ return true;
+ }
+
+ @Override
+ @SuppressLint("ClickableViewAccessibility")
+ public boolean onTouch(final View view, final MotionEvent motionEvent) {
+ binding.createMenu.collapse();
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/model/Tunnel.java b/app/src/main/java/com/wireguard/android/model/Tunnel.java
new file mode 100644
index 00000000..b196eaa5
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/model/Tunnel.java
@@ -0,0 +1,166 @@
+package com.wireguard.android.model;
+
+import android.databinding.BaseObservable;
+import android.databinding.Bindable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import com.wireguard.android.BR;
+import com.wireguard.android.backend.Backend;
+import com.wireguard.android.configStore.ConfigStore;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.config.Config;
+
+import org.threeten.bp.Instant;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * Encapsulates the volatile and nonvolatile state of a WireGuard tunnel.
+ */
+
+public class Tunnel extends BaseObservable implements Comparable<Tunnel> {
+ public static final int NAME_MAX_LENGTH = 16;
+ private static final Pattern NAME_PATTERN = Pattern.compile("[a-zA-Z0-9_=+.-]{1,16}");
+ private static final String TAG = Tunnel.class.getSimpleName();
+
+ private final Backend backend;
+ private final ConfigStore configStore;
+ private final String name;
+ private Config config;
+ private Instant lastStateChange = Instant.EPOCH;
+ private State state = State.UNKNOWN;
+ private Statistics statistics;
+
+ Tunnel(@NonNull final Backend backend, @NonNull final ConfigStore configStore,
+ @NonNull final String name, @Nullable final Config config) {
+ this.backend = backend;
+ this.configStore = configStore;
+ this.name = name;
+ this.config = config;
+ }
+
+ public static boolean isNameValid(final CharSequence name) {
+ return name != null && NAME_PATTERN.matcher(name).matches();
+ }
+
+ @Override
+ public int compareTo(@NonNull final Tunnel tunnel) {
+ return name.compareTo(tunnel.name);
+ }
+
+ @Bindable
+ public Config getConfig() {
+ if (config == null)
+ getConfigAsync().whenComplete(ExceptionLoggers.D);
+ return config;
+ }
+
+ public CompletionStage<Config> getConfigAsync() {
+ if (config == null)
+ return configStore.load(name).thenApply(this::setConfigInternal);
+ return CompletableFuture.completedFuture(config);
+ }
+
+ @Bindable
+ public Instant getLastStateChange() {
+ return lastStateChange;
+ }
+
+ @Bindable
+ public String getName() {
+ return name;
+ }
+
+ @Bindable
+ public State getState() {
+ if (state == State.UNKNOWN)
+ getStateAsync().whenComplete(ExceptionLoggers.D);
+ return state;
+ }
+
+ public CompletionStage<State> getStateAsync() {
+ if (state == State.UNKNOWN)
+ return backend.getState(this).thenApply(this::setStateInternal);
+ return CompletableFuture.completedFuture(state);
+ }
+
+ @Bindable
+ public Statistics getStatistics() {
+ // FIXME: Check age of statistics.
+ if (statistics == null)
+ getStatisticsAsync().whenComplete(ExceptionLoggers.D);
+ return statistics;
+ }
+
+ public CompletionStage<Statistics> getStatisticsAsync() {
+ // FIXME: Check age of statistics.
+ if (statistics == null)
+ return backend.getStatistics(this).thenApply(this::setStatisticsInternal);
+ return CompletableFuture.completedFuture(statistics);
+ }
+
+ private void onStateChanged(final State oldState, final State newState) {
+ if (oldState != State.UNKNOWN) {
+ lastStateChange = Instant.now();
+ notifyPropertyChanged(BR.lastStateChange);
+ }
+ if (newState != State.UP)
+ setStatisticsInternal(null);
+ }
+
+ public CompletionStage<Config> setConfig(@NonNull final Config config) {
+ if (!config.equals(this.config)) {
+ return backend.applyConfig(this, config)
+ .thenCompose(cfg -> configStore.save(name, cfg))
+ .thenApply(this::setConfigInternal);
+ }
+ return CompletableFuture.completedFuture(this.config);
+ }
+
+ private Config setConfigInternal(final Config config) {
+ if (Objects.equals(this.config, config))
+ return config;
+ this.config = config;
+ notifyPropertyChanged(BR.config);
+ return config;
+ }
+
+ public CompletionStage<State> setState(@NonNull final State state) {
+ if (state != this.state)
+ return backend.setState(this, state)
+ .thenApply(this::setStateInternal);
+ return CompletableFuture.completedFuture(this.state);
+ }
+
+ private State setStateInternal(final State state) {
+ if (Objects.equals(this.state, state))
+ return state;
+ onStateChanged(this.state, state);
+ this.state = state;
+ notifyPropertyChanged(BR.state);
+ return state;
+ }
+
+ private Statistics setStatisticsInternal(final Statistics statistics) {
+ if (Objects.equals(this.statistics, statistics))
+ return statistics;
+ this.statistics = statistics;
+ notifyPropertyChanged(BR.statistics);
+ return statistics;
+ }
+
+ public enum State {
+ DOWN,
+ TOGGLE,
+ UNKNOWN,
+ UP
+ }
+
+ public static class Statistics extends BaseObservable {
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/model/TunnelCollection.java b/app/src/main/java/com/wireguard/android/model/TunnelCollection.java
new file mode 100644
index 00000000..38b5165a
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/model/TunnelCollection.java
@@ -0,0 +1,10 @@
+package com.wireguard.android.model;
+
+import com.wireguard.android.databinding.ObservableTreeMap;
+
+/**
+ * Created by samuel on 12/19/17.
+ */
+
+public class TunnelCollection extends ObservableTreeMap<String, Tunnel> {
+}
diff --git a/app/src/main/java/com/wireguard/android/model/TunnelManager.java b/app/src/main/java/com/wireguard/android/model/TunnelManager.java
new file mode 100644
index 00000000..5122f9bf
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/model/TunnelManager.java
@@ -0,0 +1,111 @@
+package com.wireguard.android.model;
+
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import com.wireguard.android.Application.ApplicationScope;
+import com.wireguard.android.backend.Backend;
+import com.wireguard.android.configStore.ConfigStore;
+import com.wireguard.android.model.Tunnel.State;
+import com.wireguard.android.util.ExceptionLoggers;
+import com.wireguard.config.Config;
+
+import java.util.Collections;
+import java.util.Set;
+
+import javax.inject.Inject;
+
+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,
+ */
+
+@ApplicationScope
+public final class TunnelManager {
+ public static final String KEY_PRIMARY_TUNNEL = "primary_config";
+ public static final String KEY_SELECTED_TUNNEL = "selected_tunnel";
+ private static final String KEY_RESTORE_ON_BOOT = "restore_on_boot";
+ private static final String KEY_RUNNING_TUNNELS = "enabled_configs";
+ private static final String TAG = TunnelManager.class.getSimpleName();
+
+ private final Backend backend;
+ private final ConfigStore configStore;
+ private final SharedPreferences preferences;
+ private final TunnelCollection tunnels = new TunnelCollection();
+
+ @Inject
+ public TunnelManager(final Backend backend, final ConfigStore configStore,
+ final SharedPreferences preferences) {
+ this.backend = backend;
+ this.configStore = configStore;
+ this.preferences = preferences;
+ }
+
+ private Tunnel add(final String name, final Config config) {
+ final Tunnel tunnel = new Tunnel(backend, configStore, name, config);
+ tunnels.put(name, tunnel);
+ return tunnel;
+ }
+
+ private Tunnel add(final String name) {
+ return add(name, null);
+ }
+
+ public CompletionStage<Tunnel> create(final String name, final Config config) {
+ Log.v(TAG, "Requested create tunnel " + name + " with config\n" + config);
+ if (!Tunnel.isNameValid(name))
+ return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid name"));
+ if (tunnels.containsKey(name)) {
+ final String message = "Tunnel " + name + " already exists";
+ return CompletableFuture.failedFuture(new IllegalArgumentException(message));
+ }
+ return configStore.create(name, config).thenApply(savedConfig -> add(name, savedConfig));
+ }
+
+ public CompletionStage<Void> delete(final Tunnel tunnel) {
+ Log.v(TAG, "Requested delete tunnel " + tunnel.getName() + " state=" + tunnel.getState());
+ return backend.setState(tunnel, State.DOWN)
+ .thenCompose(x -> configStore.delete(tunnel.getName()))
+ .thenAccept(x -> tunnels.remove(tunnel.getName()));
+ }
+
+ public TunnelCollection getTunnels() {
+ return tunnels;
+ }
+
+ public void onCreate() {
+ Log.v(TAG, "onCreate triggered");
+ configStore.enumerate()
+ .thenApply(names -> StreamSupport.stream(names)
+ .map(this::add)
+ .map(Tunnel::getStateAsync)
+ .toArray(CompletableFuture[]::new))
+ .thenCompose(CompletableFuture::allOf)
+ .whenComplete(ExceptionLoggers.E);
+ }
+
+ public CompletionStage<Void> restoreState() {
+ if (!preferences.getBoolean(KEY_RESTORE_ON_BOOT, false))
+ return CompletableFuture.completedFuture(null);
+ final Set<String> tunnelsToEnable =
+ preferences.getStringSet(KEY_RUNNING_TUNNELS, Collections.emptySet());
+ final CompletableFuture[] futures = StreamSupport.stream(tunnelsToEnable)
+ .map(tunnels::get)
+ .map(tunnel -> tunnel.setState(State.UP))
+ .toArray(CompletableFuture[]::new);
+ return CompletableFuture.allOf(futures);
+ }
+
+ public CompletionStage<Void> saveState() {
+ final Set<String> runningTunnels = StreamSupport.stream(tunnels.values())
+ .filter(tunnel -> tunnel.getState() == State.UP)
+ .map(Tunnel::getName)
+ .collect(Collectors.toUnmodifiableSet());
+ preferences.edit().putStringSet(KEY_RUNNING_TUNNELS, runningTunnels).apply();
+ return CompletableFuture.completedFuture(null);
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/ConfigListPreference.java b/app/src/main/java/com/wireguard/android/preference/TunnelListPreference.java
index e0bcc672..541afa28 100644
--- a/app/src/main/java/com/wireguard/android/ConfigListPreference.java
+++ b/app/src/main/java/com/wireguard/android/preference/TunnelListPreference.java
@@ -1,10 +1,10 @@
-package com.wireguard.android;
+package com.wireguard.android.preference;
import android.content.Context;
import android.preference.ListPreference;
import android.util.AttributeSet;
-import com.wireguard.android.backends.VpnService;
+import com.wireguard.android.Application;
import java.util.Set;
@@ -12,28 +12,30 @@ import java.util.Set;
* ListPreference that is automatically filled with the list of configurations.
*/
-public class ConfigListPreference extends ListPreference {
- public ConfigListPreference(final Context context, final AttributeSet attrs,
+public class TunnelListPreference extends ListPreference {
+ public TunnelListPreference(final Context context, final AttributeSet attrs,
final int defStyleAttr, final int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
- final Set<String> entrySet = VpnService.getInstance().getConfigs().keySet();
+ final Set<String> entrySet = Application.getComponent().getTunnelManager().getTunnels().keySet();
final CharSequence[] entries = entrySet.toArray(new CharSequence[entrySet.size()]);
setEntries(entries);
setEntryValues(entries);
}
- public ConfigListPreference(final Context context, final AttributeSet attrs,
+ public TunnelListPreference(final Context context, final AttributeSet attrs,
final int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
- public ConfigListPreference(final Context context, final AttributeSet attrs) {
+ public TunnelListPreference(final Context context, final AttributeSet attrs) {
this(context, attrs, android.R.attr.dialogPreferenceStyle);
}
- public ConfigListPreference(final Context context) {
+ public TunnelListPreference(final Context context) {
this(context, null);
}
- public void show() { showDialog(null); }
+ public void show() {
+ showDialog(null);
+ }
}
diff --git a/app/src/main/java/com/wireguard/android/util/AsyncWorker.java b/app/src/main/java/com/wireguard/android/util/AsyncWorker.java
new file mode 100644
index 00000000..5f9f0a83
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/util/AsyncWorker.java
@@ -0,0 +1,65 @@
+package com.wireguard.android.util;
+
+import android.os.Handler;
+
+import com.wireguard.android.Application.ApplicationHandler;
+import com.wireguard.android.Application.ApplicationScope;
+
+import java.util.concurrent.Executor;
+
+import javax.inject.Inject;
+
+import java9.util.concurrent.CompletableFuture;
+import java9.util.concurrent.CompletionStage;
+
+/**
+ * Helper class for running asynchronous tasks and ensuring they are completed on the main thread.
+ */
+
+@ApplicationScope
+public class AsyncWorker {
+ private final Executor executor;
+ private final Handler handler;
+
+ @Inject
+ public AsyncWorker(final Executor executor, @ApplicationHandler final Handler handler) {
+ this.executor = executor;
+ this.handler = handler;
+ }
+
+ public CompletionStage<Void> runAsync(final AsyncRunnable<?> runnable) {
+ final CompletableFuture<Void> future = new CompletableFuture<>();
+ executor.execute(() -> {
+ try {
+ runnable.run();
+ handler.post(() -> future.complete(null));
+ } catch (final Throwable t) {
+ handler.post(() -> future.completeExceptionally(t));
+ }
+ });
+ return future;
+ }
+
+ public <T> CompletionStage<T> supplyAsync(final AsyncSupplier<T, ?> supplier) {
+ final CompletableFuture<T> future = new CompletableFuture<>();
+ executor.execute(() -> {
+ try {
+ final T result = supplier.get();
+ handler.post(() -> future.complete(result));
+ } catch (final Throwable t) {
+ handler.post(() -> future.completeExceptionally(t));
+ }
+ });
+ return future;
+ }
+
+ @FunctionalInterface
+ public interface AsyncRunnable<E extends Throwable> {
+ void run() throws E;
+ }
+
+ @FunctionalInterface
+ public interface AsyncSupplier<T, E extends Throwable> {
+ T get() throws E;
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/util/ClipboardUtils.java b/app/src/main/java/com/wireguard/android/util/ClipboardUtils.java
new file mode 100644
index 00000000..20aeffff
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/util/ClipboardUtils.java
@@ -0,0 +1,32 @@
+package com.wireguard.android.util;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.view.View;
+import android.widget.TextView;
+
+import com.commonsware.cwac.crossport.design.widget.Snackbar;
+
+/**
+ * Created by samuel on 12/30/17.
+ */
+
+public final class ClipboardUtils {
+ private ClipboardUtils() {
+ }
+
+ 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/app/src/main/java/com/wireguard/android/util/ExceptionLoggers.java b/app/src/main/java/com/wireguard/android/util/ExceptionLoggers.java
new file mode 100644
index 00000000..a11529f4
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/util/ExceptionLoggers.java
@@ -0,0 +1,38 @@
+package com.wireguard.android.util;
+
+import android.util.Log;
+
+import java9.util.concurrent.CompletionException;
+import java9.util.function.BiConsumer;
+
+/**
+ * Helpers for logging exceptions from asynchronous tasks. These can be passed to
+ * {@code CompletionStage.handle()} 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 = ExceptionLoggers.class.getSimpleName();
+
+ private final int priority;
+
+ ExceptionLoggers(final int priority) {
+ this.priority = priority;
+ }
+
+ public static Throwable unwrap(final Throwable throwable) {
+ if (throwable instanceof CompletionException)
+ return throwable.getCause();
+ return throwable;
+ }
+
+ @Override
+ public void accept(final Object result, 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/app/src/main/java/com/wireguard/android/backends/RootShell.java b/app/src/main/java/com/wireguard/android/util/RootShell.java
index cab39730..35f4735b 100644
--- a/app/src/main/java/com/wireguard/android/backends/RootShell.java
+++ b/app/src/main/java/com/wireguard/android/util/RootShell.java
@@ -1,9 +1,12 @@
-package com.wireguard.android.backends;
+package com.wireguard.android.util;
import android.content.Context;
import android.system.OsConstants;
import android.util.Log;
+import com.wireguard.android.Application.ApplicationContext;
+import com.wireguard.android.Application.ApplicationScope;
+
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
@@ -11,29 +14,32 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
-import java.util.regex.Pattern;
import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.inject.Inject;
/**
* Helper class for running commands as root.
*/
+@ApplicationScope
public class RootShell {
+ private static final Pattern ERRNO_EXTRACTOR = Pattern.compile("error=(\\d+)");
/**
* Setup commands that are run at the beginning of each root shell. The trap command ensures
* access to the return value of the last command, since su itself always exits with 0.
*/
private static final String TAG = "WireGuard/RootShell";
- private static final Pattern ERRNO_EXTRACTOR = Pattern.compile("error=(\\d+)");
-
private static final String[][] libraryNamedExecutables = {
- { "libwg.so", "wg" },
- { "libwg-quick.so", "wg-quick" }
+ {"libwg.so", "wg"},
+ {"libwg-quick.so", "wg-quick"}
};
private final String preamble;
- public RootShell(final Context context) {
+ @Inject
+ public RootShell(@ApplicationContext final Context context) {
final String binDir = context.getCacheDir().getPath() + "/bin";
final String tmpDir = context.getCacheDir().getPath() + "/tmp";
final String libDir = context.getApplicationInfo().nativeLibraryDir;
@@ -55,9 +61,9 @@ public class RootShell {
/**
* Run a command in a root shell.
*
- * @param output Lines read from stdout are appended to this list. Pass null if the
- * output from the shell is not important.
- * @param command Command to run as root.
+ * @param output Lines read from stdout are appended to this list. Pass null if the
+ * output from the shell is not important.
+ * @param command Command to run as root.
* @return The exit value of the last command run, or -1 if there was an internal error.
*/
public int run(final List<String> output, final String command) {
diff --git a/app/src/main/java/com/wireguard/android/KeyInputFilter.java b/app/src/main/java/com/wireguard/android/widget/KeyInputFilter.java
index d8eabd6c..45652ed5 100644
--- a/app/src/main/java/com/wireguard/android/KeyInputFilter.java
+++ b/app/src/main/java/com/wireguard/android/widget/KeyInputFilter.java
@@ -1,4 +1,4 @@
-package com.wireguard.android;
+package com.wireguard.android.widget;
import android.text.InputFilter;
import android.text.SpannableStringBuilder;
diff --git a/app/src/main/java/com/wireguard/android/NameInputFilter.java b/app/src/main/java/com/wireguard/android/widget/NameInputFilter.java
index cd653c11..8c91e682 100644
--- a/app/src/main/java/com/wireguard/android/NameInputFilter.java
+++ b/app/src/main/java/com/wireguard/android/widget/NameInputFilter.java
@@ -1,10 +1,10 @@
-package com.wireguard.android;
+package com.wireguard.android.widget;
import android.text.InputFilter;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
-import com.wireguard.config.Config;
+import com.wireguard.android.model.Tunnel;
/**
* InputFilter for entering WireGuard configuration names (Linux interface names).
@@ -28,8 +28,8 @@ public class NameInputFilter implements InputFilter {
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 < Config.NAME_MAX_LENGTH && isAllowed(c)) &&
- dLength + (sIndex - sStart) < Config.NAME_MAX_LENGTH) {
+ if ((dIndex < Tunnel.NAME_MAX_LENGTH && isAllowed(c)) &&
+ dLength + (sIndex - sStart) < Tunnel.NAME_MAX_LENGTH) {
++rIndex;
} else {
if (replacement == null)
diff --git a/app/src/main/java/com/wireguard/android/widget/ToggleSwitch.java b/app/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
index c8f9ab2d..1b3dee71 100644
--- a/app/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
+++ b/app/src/main/java/com/wireguard/android/widget/ToggleSwitch.java
@@ -17,17 +17,15 @@
package com.wireguard.android.widget;
import android.content.Context;
+import android.os.Parcelable;
import android.util.AttributeSet;
import android.widget.Switch;
public class ToggleSwitch extends Switch {
private boolean hasPendingStateChange;
+ private boolean isRestoringState;
private OnBeforeCheckedChangeListener listener;
- public interface OnBeforeCheckedChangeListener {
- void onBeforeCheckedChanged(ToggleSwitch toggleSwitch, boolean checked);
- }
-
public ToggleSwitch(final Context context) {
super(context);
}
@@ -45,21 +43,25 @@ public class ToggleSwitch extends Switch {
super(context, attrs, defStyleAttr, defStyleRes);
}
- public void setOnBeforeCheckedChangeListener(final OnBeforeCheckedChangeListener listener) {
- this.listener = listener;
+ @Override
+ public void onRestoreInstanceState(final Parcelable state) {
+ isRestoringState = true;
+ super.onRestoreInstanceState(state);
+ isRestoringState = false;
+
}
@Override
public void setChecked(final boolean checked) {
- if (listener != null) {
- if (!isEnabled())
- return;
- setEnabled(false);
- hasPendingStateChange = true;
- listener.onBeforeCheckedChanged(this, checked);
- } else {
+ if (isRestoringState || listener == null) {
super.setChecked(checked);
+ return;
}
+ if (hasPendingStateChange)
+ return;
+ hasPendingStateChange = true;
+ setEnabled(false);
+ listener.onBeforeCheckedChanged(this, checked);
}
public void setCheckedInternal(final boolean checked) {
@@ -69,4 +71,12 @@ public class ToggleSwitch extends Switch {
}
super.setChecked(checked);
}
+
+ public void setOnBeforeCheckedChangeListener(final OnBeforeCheckedChangeListener listener) {
+ this.listener = listener;
+ }
+
+ public interface OnBeforeCheckedChangeListener {
+ void onBeforeCheckedChanged(ToggleSwitch toggleSwitch, boolean checked);
+ }
}
diff --git a/app/src/main/java/com/wireguard/config/Attribute.java b/app/src/main/java/com/wireguard/config/Attribute.java
index b2aa0d53..ee7fea33 100644
--- a/app/src/main/java/com/wireguard/config/Attribute.java
+++ b/app/src/main/java/com/wireguard/config/Attribute.java
@@ -21,38 +21,38 @@ enum Attribute {
PRIVATE_KEY("PrivateKey"),
PUBLIC_KEY("PublicKey");
- private static final Map<String, Attribute> map;
+ private static final Map<String, Attribute> KEY_MAP;
+ private static final Pattern SEPARATOR_PATTERN = Pattern.compile("\\s|=");
static {
- map = new HashMap<>(Attribute.values().length);
- for (final Attribute key : Attribute.values())
- map.put(key.getToken(), key);
+ KEY_MAP = new HashMap<>(Attribute.values().length);
+ for (final Attribute key : Attribute.values()) {
+ KEY_MAP.put(key.token, key);
+ }
}
- public static Attribute match(final String line) {
- return map.get(line.split("\\s|=")[0]);
- }
-
- private final String token;
private final Pattern pattern;
+ private final String token;
Attribute(final String token) {
pattern = Pattern.compile(token + "\\s*=\\s*(\\S.*)");
this.token = token;
}
- public String composeWith(final String value) {
- return token + " = " + value + "\n";
+ public static Attribute match(final CharSequence line) {
+ return KEY_MAP.get(SEPARATOR_PATTERN.split(line)[0]);
+ }
+
+ public String composeWith(final Object value) {
+ return String.format("%s = %s%n", token, value);
}
public String getToken() {
return token;
}
- public String parseFrom(final String line) {
+ public String parse(final CharSequence line) {
final Matcher matcher = pattern.matcher(line);
- if (matcher.matches())
- return matcher.group(1);
- return null;
+ return matcher.matches() ? matcher.group(1) : null;
}
}
diff --git a/app/src/main/java/com/wireguard/config/Config.java b/app/src/main/java/com/wireguard/config/Config.java
index a3935302..a3341d5f 100644
--- a/app/src/main/java/com/wireguard/config/Config.java
+++ b/app/src/main/java/com/wireguard/config/Config.java
@@ -1,32 +1,23 @@
package com.wireguard.config;
import android.databinding.BaseObservable;
-import android.databinding.Bindable;
-import android.databinding.Observable;
import android.databinding.ObservableArrayList;
import android.databinding.ObservableList;
import android.os.Parcel;
import android.os.Parcelable;
-import android.support.annotation.NonNull;
-
-import com.wireguard.android.BR;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.regex.Pattern;
/**
* Represents a wg-quick configuration file, its name, and its connection state.
*/
-public class Config extends BaseObservable
- implements Comparable<Config>, Copyable<Config>, Observable, Parcelable {
- public static final Parcelable.Creator<Config> CREATOR = new Parcelable.Creator<Config>() {
+public class Config extends BaseObservable implements Parcelable {
+ public static final Creator<Config> CREATOR = new Creator<Config>() {
@Override
public Config createFromParcel(final Parcel in) {
return new Config(in);
@@ -37,104 +28,22 @@ public class Config extends BaseObservable
return new Config[size];
}
};
- public static final int NAME_MAX_LENGTH = 16;
- private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9_=+.-]{1,16}$");
-
- public static boolean isNameValid(final String name) {
- return name.length() <= NAME_MAX_LENGTH && PATTERN.matcher(name).matches();
- }
- private final Interface iface;
- private boolean isEnabled;
- private boolean isPrimary;
- private String name;
+ private final Interface interfaceSection;
private final ObservableList<Peer> peers = new ObservableArrayList<>();
public Config() {
- iface = new Interface();
+ interfaceSection = new Interface();
}
- protected Config(final Parcel in) {
- iface = in.readParcelable(Interface.class.getClassLoader());
- name = in.readString();
- // The flattened peers must be recreated to associate them with this config.
- final List<Peer> flattenedPeers = new LinkedList<>();
- in.readTypedList(flattenedPeers, Peer.CREATOR);
- for (final Peer peer : flattenedPeers)
- addPeer(peer);
+ private Config(final Parcel in) {
+ interfaceSection = in.readParcelable(Interface.class.getClassLoader());
+ in.readTypedList(peers, Peer.CREATOR);
}
- public Peer addPeer() {
- final Peer peer = new Peer(this);
- peers.add(peer);
- return peer;
- }
-
- private Peer addPeer(final Peer peer) {
- final Peer copy = peer.copy(this);
- peers.add(copy);
- return copy;
- }
-
- @Override
- public int compareTo(@NonNull final Config config) {
- return getName().compareTo(config.getName());
- }
-
- @Override
- public Config copy() {
- final Config copy = new Config();
- copy.copyFrom(this);
- return copy;
- }
-
- @Override
- public void copyFrom(final Config source) {
- if (source != null) {
- iface.copyFrom(source.iface);
- name = source.name;
- peers.clear();
- for (final Peer peer : source.peers)
- addPeer(peer);
- } else {
- iface.copyFrom(null);
- name = null;
- peers.clear();
- }
- notifyChange();
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- public Interface getInterface() {
- return iface;
- }
-
- @Bindable
- public String getName() {
- return name;
- }
-
- public ObservableList<Peer> getPeers() {
- return peers;
- }
-
- @Bindable
- public boolean isEnabled() {
- return isEnabled;
- }
-
- @Bindable
- public boolean isPrimary() {
- return isPrimary;
- }
-
- public void parseFrom(final InputStream stream)
+ public static Config from(final InputStream stream)
throws IOException {
- peers.clear();
+ final Config config = new Config();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
Peer currentPeer = null;
@@ -147,10 +56,11 @@ public class Config extends BaseObservable
currentPeer = null;
inInterfaceSection = true;
} else if ("[Peer]".equals(line)) {
- currentPeer = addPeer();
+ currentPeer = new Peer();
+ config.peers.add(currentPeer);
inInterfaceSection = false;
} else if (inInterfaceSection) {
- iface.parse(line);
+ config.interfaceSection.parse(line);
} else if (currentPeer != null) {
currentPeer.parse(line);
} else {
@@ -161,28 +71,25 @@ public class Config extends BaseObservable
throw new IllegalArgumentException("Could not find any config information");
}
}
+ return config;
}
- public void setIsEnabled(final boolean isEnabled) {
- this.isEnabled = isEnabled;
- notifyPropertyChanged(BR.enabled);
+ @Override
+ public int describeContents() {
+ return 0;
}
- public void setIsPrimary(final boolean isPrimary) {
- this.isPrimary = isPrimary;
- notifyPropertyChanged(BR.primary);
+ public Interface getInterface() {
+ return interfaceSection;
}
- public void setName(final String name) {
- if (name != null && !name.isEmpty() && !isNameValid(name))
- throw new IllegalArgumentException();
- this.name = name;
- notifyPropertyChanged(BR.name);
+ public ObservableList<Peer> getPeers() {
+ return peers;
}
@Override
public String toString() {
- final StringBuilder sb = new StringBuilder().append(iface);
+ final StringBuilder sb = new StringBuilder().append(interfaceSection);
for (final Peer peer : peers)
sb.append('\n').append(peer);
return sb.toString();
@@ -190,8 +97,7 @@ public class Config extends BaseObservable
@Override
public void writeToParcel(final Parcel dest, final int flags) {
- dest.writeParcelable(iface, flags);
- dest.writeString(name);
+ dest.writeParcelable(interfaceSection, flags);
dest.writeTypedList(peers);
}
}
diff --git a/app/src/main/java/com/wireguard/config/Copyable.java b/app/src/main/java/com/wireguard/config/Copyable.java
deleted file mode 100644
index 826cfbb5..00000000
--- a/app/src/main/java/com/wireguard/config/Copyable.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.wireguard.config;
-
-/**
- * Interface for classes that can perform a deep copy of their objects.
- */
-
-public interface Copyable<T> {
- T copy();
- void copyFrom(T source);
-}
diff --git a/app/src/main/java/com/wireguard/config/Interface.java b/app/src/main/java/com/wireguard/config/Interface.java
index b291844a..431f0e31 100644
--- a/app/src/main/java/com/wireguard/config/Interface.java
+++ b/app/src/main/java/com/wireguard/config/Interface.java
@@ -2,7 +2,6 @@ package com.wireguard.config;
import android.databinding.BaseObservable;
import android.databinding.Bindable;
-import android.databinding.Observable;
import android.os.Parcel;
import android.os.Parcelable;
@@ -14,10 +13,8 @@ import com.wireguard.crypto.Keypair;
* Represents the configuration for a WireGuard interface (an [Interface] block).
*/
-public class Interface extends BaseObservable
- implements Copyable<Interface>, Observable, Parcelable {
- public static final Parcelable.Creator<Interface> CREATOR
- = new Parcelable.Creator<Interface>() {
+public class Interface extends BaseObservable implements Parcelable {
+ public static final Creator<Interface> CREATOR = new Creator<Interface>() {
@Override
public Interface createFromParcel(final Parcel in) {
return new Interface(in);
@@ -31,8 +28,8 @@ public class Interface extends BaseObservable
private String address;
private String dns;
- private String listenPort;
private Keypair keypair;
+ private String listenPort;
private String mtu;
private String privateKey;
@@ -40,7 +37,7 @@ public class Interface extends BaseObservable
// Do nothing.
}
- protected Interface(final Parcel in) {
+ private Interface(final Parcel in) {
address = in.readString();
dns = in.readString();
listenPort = in.readString();
@@ -49,31 +46,6 @@ public class Interface extends BaseObservable
}
@Override
- public Interface copy() {
- final Interface copy = new Interface();
- copy.copyFrom(this);
- return copy;
- }
-
- @Override
- public void copyFrom(final Interface source) {
- if (source != null) {
- address = source.address;
- dns = source.dns;
- listenPort = source.listenPort;
- mtu = source.mtu;
- setPrivateKey(source.privateKey);
- } else {
- address = null;
- dns = null;
- listenPort = null;
- mtu = null;
- setPrivateKey(null);
- }
- notifyChange();
- }
-
- @Override
public int describeContents() {
return 0;
}
@@ -118,15 +90,15 @@ public class Interface extends BaseObservable
public void parse(final String line) {
final Attribute key = Attribute.match(line);
if (key == Attribute.ADDRESS)
- setAddress(key.parseFrom(line));
+ setAddress(key.parse(line));
else if (key == Attribute.DNS)
- setDns(key.parseFrom(line));
+ setDns(key.parse(line));
else if (key == Attribute.LISTEN_PORT)
- setListenPort(key.parseFrom(line));
+ setListenPort(key.parse(line));
else if (key == Attribute.MTU)
- setMtu(key.parseFrom(line));
+ setMtu(key.parse(line));
else if (key == Attribute.PRIVATE_KEY)
- setPrivateKey(key.parseFrom(line));
+ setPrivateKey(key.parse(line));
else
throw new IllegalArgumentException(line);
}
diff --git a/app/src/main/java/com/wireguard/config/Peer.java b/app/src/main/java/com/wireguard/config/Peer.java
index ea73155f..29a55f9b 100644
--- a/app/src/main/java/com/wireguard/config/Peer.java
+++ b/app/src/main/java/com/wireguard/config/Peer.java
@@ -2,7 +2,6 @@ package com.wireguard.config;
import android.databinding.BaseObservable;
import android.databinding.Bindable;
-import android.databinding.Observable;
import android.os.Parcel;
import android.os.Parcelable;
@@ -12,8 +11,8 @@ import com.android.databinding.library.baseAdapters.BR;
* Represents the configuration for a WireGuard peer (a [Peer] block).
*/
-public class Peer extends BaseObservable implements Copyable<Peer>, Observable, Parcelable {
- public static final Parcelable.Creator<Peer> CREATOR = new Parcelable.Creator<Peer>() {
+public class Peer extends BaseObservable implements Parcelable {
+ public static final Creator<Peer> CREATOR = new Creator<Peer>() {
@Override
public Peer createFromParcel(final Parcel in) {
return new Peer(in);
@@ -26,44 +25,25 @@ public class Peer extends BaseObservable implements Copyable<Peer>, Observable,
};
private String allowedIPs;
- private final Config config;
private String endpoint;
private String persistentKeepalive;
private String preSharedKey;
private String publicKey;
- public Peer(final Config config) {
- this.config = config;
+ public Peer() {
+ // Do nothing.
}
- protected Peer(final Parcel in) {
+ private Peer(final Parcel in) {
allowedIPs = in.readString();
- config = null;
endpoint = in.readString();
persistentKeepalive = in.readString();
preSharedKey = in.readString();
publicKey = in.readString();
}
- @Override
- public Peer copy() {
- return copy(config);
- }
-
- public Peer copy(final Config config) {
- final Peer copy = new Peer(config);
- copy.copyFrom(this);
- return copy;
- }
-
- @Override
- public void copyFrom(final Peer source) {
- allowedIPs = source.allowedIPs;
- endpoint = source.endpoint;
- persistentKeepalive = source.persistentKeepalive;
- preSharedKey = source.preSharedKey;
- publicKey = source.publicKey;
- notifyChange();
+ public static Peer newInstance() {
+ return new Peer();
}
@Override
@@ -99,24 +79,19 @@ public class Peer extends BaseObservable implements Copyable<Peer>, Observable,
public void parse(final String line) {
final Attribute key = Attribute.match(line);
if (key == Attribute.ALLOWED_IPS)
- setAllowedIPs(key.parseFrom(line));
+ setAllowedIPs(key.parse(line));
else if (key == Attribute.ENDPOINT)
- setEndpoint(key.parseFrom(line));
+ setEndpoint(key.parse(line));
else if (key == Attribute.PERSISTENT_KEEPALIVE)
- setPersistentKeepalive(key.parseFrom(line));
+ setPersistentKeepalive(key.parse(line));
else if (key == Attribute.PRESHARED_KEY)
- setPreSharedKey(key.parseFrom(line));
+ setPreSharedKey(key.parse(line));
else if (key == Attribute.PUBLIC_KEY)
- setPublicKey(key.parseFrom(line));
+ setPublicKey(key.parse(line));
else
throw new IllegalArgumentException(line);
}
- public void removeSelf() {
- if (!config.getPeers().remove(this))
- throw new IllegalStateException("This peer was already removed from its config");
- }
-
public void setAllowedIPs(String allowedIPs) {
if (allowedIPs != null && allowedIPs.isEmpty())
allowedIPs = null;
diff --git a/app/src/main/res/layout-w720dp/config_activity.xml b/app/src/main/res/layout-w720dp/config_activity.xml
deleted file mode 100644
index 72792af5..00000000
--- a/app/src/main/res/layout-w720dp/config_activity.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:baselineAligned="false"
- android:orientation="horizontal">
-
- <FrameLayout
- android:id="@+id/master_fragment"
- android:layout_width="0dp"
- android:layout_height="match_parent"
- android:layout_weight="1" />
-
- <FrameLayout
- android:id="@+id/detail_fragment"
- android:layout_width="0dp"
- android:layout_height="match_parent"
- android:layout_weight="2"
- tools:ignore="InconsistentLayout">
-
- <TextView
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:gravity="center"
- android:text="@string/placeholder_text" />
- </FrameLayout>
-</LinearLayout>
diff --git a/app/src/main/res/layout/config_activity.xml b/app/src/main/res/layout/config_activity.xml
deleted file mode 100644
index 0f21e2e8..00000000
--- a/app/src/main/res/layout/config_activity.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:id="@+id/master_fragment"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- tools:ignore="MergeRootFrame" />
diff --git a/app/src/main/res/layout/config_detail_fragment.xml b/app/src/main/res/layout/config_detail_fragment.xml
deleted file mode 100644
index cf6fdaaf..00000000
--- a/app/src/main/res/layout/config_detail_fragment.xml
+++ /dev/null
@@ -1,86 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<layout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools">
-
- <data>
-
- <import type="com.wireguard.android.backends.VpnService" />
-
- <variable
- name="config"
- type="com.wireguard.config.Config" />
- </data>
-
- <ScrollView
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="?android:attr/colorBackground">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical">
-
- <RelativeLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="4dp"
- android:layout_marginEnd="8dp"
- android:layout_marginStart="8dp"
- android:layout_marginTop="8dp"
- android:background="?android:attr/colorBackground"
- android:elevation="2dp"
- android:padding="8dp">
-
- <TextView
- android:id="@+id/status_label"
- style="?android:attr/textAppearanceMedium"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentStart="true"
- android:layout_alignParentTop="true"
- android:layout_marginBottom="8dp"
- android:layout_toStartOf="@+id/config_switch"
- android:text="@string/status" />
-
- <com.wireguard.android.widget.ToggleSwitch
- android:id="@+id/config_switch"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignBaseline="@+id/status_label"
- android:layout_alignParentEnd="true"
- app:checked="@{config.enabled}"
- app:onBeforeCheckedChanged="@{(v, checked) -> checked ? VpnService.instance.enable(config.name) : VpnService.instance.disable(config.name)}" />
-
- <TextView
- android:id="@+id/public_key_label"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@id/status_label"
- android:labelFor="@+id/public_key_text"
- android:text="@string/public_key" />
-
- <TextView
- android:id="@+id/public_key_text"
- style="?android:attr/textAppearanceMedium"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@+id/public_key_label"
- android:ellipsize="end"
- android:maxLines="1"
- android:text="@{config.interface.publicKey}" />
- </RelativeLayout>
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="4dp"
- android:divider="@null"
- android:orientation="vertical"
- app:items="@{config.peers}"
- app:layout="@{@layout/config_detail_peer}"
- tools:ignore="UselessLeaf" />
- </LinearLayout>
- </ScrollView>
-</layout>
diff --git a/app/src/main/res/layout/config_edit_fragment.xml b/app/src/main/res/layout/config_edit_fragment.xml
deleted file mode 100644
index f7721ac1..00000000
--- a/app/src/main/res/layout/config_edit_fragment.xml
+++ /dev/null
@@ -1,219 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<layout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools">
-
- <data>
-
- <import type="com.wireguard.android.ConfigEditFragment" />
-
- <import type="com.wireguard.android.KeyInputFilter" />
-
- <import type="com.wireguard.android.NameInputFilter" />
-
- <variable
- name="config"
- type="com.wireguard.config.Config" />
- </data>
-
- <ScrollView
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="?android:attr/colorBackground">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical">
-
- <RelativeLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="4dp"
- android:layout_marginEnd="8dp"
- android:layout_marginStart="8dp"
- android:layout_marginTop="8dp"
- android:background="?android:attr/colorBackground"
- android:elevation="2dp"
- android:padding="8dp">
-
- <TextView
- android:id="@+id/interface_title"
- style="?android:attr/textAppearanceMedium"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_alignParentTop="true"
- android:text="@string/iface" />
-
- <TextView
- android:id="@+id/interface_name_label"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@+id/interface_title"
- android:labelFor="@+id/interface_name_text"
- android:text="@string/name" />
-
- <EditText
- android:id="@+id/interface_name_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@+id/interface_name_label"
- android:inputType="textNoSuggestions"
- android:text="@={config.name}"
- app:filter="@{NameInputFilter.newInstance()}" />
-
- <TextView
- android:id="@+id/private_key_label"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@+id/interface_name_text"
- android:labelFor="@+id/private_key_text"
- android:text="@string/private_key" />
-
- <EditText
- android:id="@+id/private_key_text"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentStart="true"
- android:layout_below="@+id/private_key_label"
- android:layout_toStartOf="@+id/generate_private_key_button"
- android:inputType="textVisiblePassword"
- android:text="@={config.interface.privateKey}"
- app:filter="@{KeyInputFilter.newInstance()}" />
-
- <Button
- android:id="@+id/generate_private_key_button"
- android:layout_width="96dp"
- android:layout_height="wrap_content"
- android:layout_alignBottom="@id/private_key_text"
- android:layout_alignParentEnd="true"
- android:layout_below="@+id/private_key_label"
- android:onClick="@{() -> config.interface.generateKeypair()}"
- android:text="@string/generate" />
-
- <TextView
- android:id="@+id/public_key_label"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@+id/private_key_text"
- android:labelFor="@+id/public_key_text"
- android:text="@string/public_key" />
-
- <TextView
- android:id="@+id/public_key_text"
- style="?android:attr/editTextStyle"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_below="@+id/public_key_label"
- android:ellipsize="end"
- android:focusable="false"
- android:hint="@string/hint_generated"
- android:maxLines="1"
- android:onClick="@{(view) -> ConfigEditFragment.copyPublicKey(view.getContext(), config.interface.publicKey)}"
- android:text="@{config.interface.publicKey}" />
-
- <TextView
- android:id="@+id/addresses_label"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentStart="true"
- android:layout_below="@+id/public_key_text"
- android:layout_toStartOf="@+id/listen_port_label"
- android:labelFor="@+id/addresses_text"
- android:text="@string/addresses" />
-
- <EditText
- android:id="@+id/addresses_text"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentStart="true"
- android:layout_below="@+id/addresses_label"
- android:layout_toStartOf="@+id/listen_port_text"
- android:inputType="textNoSuggestions"
- android:text="@={config.interface.address}" />
-
- <TextView
- android:id="@+id/listen_port_label"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignBaseline="@+id/addresses_label"
- android:layout_alignParentEnd="true"
- android:layout_alignStart="@+id/generate_private_key_button"
- android:labelFor="@+id/listen_port_text"
- android:text="@string/listen_port" />
-
- <EditText
- android:id="@+id/listen_port_text"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignBaseline="@+id/addresses_text"
- android:layout_alignParentEnd="true"
- android:layout_alignStart="@+id/generate_private_key_button"
- android:hint="@string/hint_random"
- android:inputType="number"
- android:text="@={config.interface.listenPort}"
- android:textAlignment="center" />
-
- <TextView
- android:id="@+id/dns_servers_label"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentStart="true"
- android:layout_below="@+id/addresses_text"
- android:layout_toStartOf="@+id/mtu_label"
- android:labelFor="@+id/dns_servers_text"
- android:text="@string/dns_servers" />
-
- <EditText
- android:id="@+id/dns_servers_text"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentStart="true"
- android:layout_below="@+id/dns_servers_label"
- android:layout_toStartOf="@+id/mtu_text"
- android:inputType="textNoSuggestions"
- android:text="@={config.interface.dns}" />
-
- <TextView
- android:id="@+id/mtu_label"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignBaseline="@+id/dns_servers_label"
- android:layout_alignParentEnd="true"
- android:layout_alignStart="@+id/generate_private_key_button"
- android:labelFor="@+id/mtu_text"
- android:text="@string/mtu" />
-
- <EditText
- android:id="@+id/mtu_text"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignBaseline="@+id/dns_servers_text"
- android:layout_alignParentEnd="true"
- android:layout_alignStart="@+id/generate_private_key_button"
- android:hint="@string/hint_automatic"
- android:inputType="number"
- android:text="@={config.interface.mtu}"
- android:textAlignment="center" />
- </RelativeLayout>
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:divider="@null"
- android:orientation="vertical"
- app:items="@{config.peers}"
- app:layout="@{@layout/config_edit_peer}"
- tools:ignore="UselessLeaf" />
-
- <Button
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="4dp"
- android:layout_marginEnd="4dp"
- android:layout_marginStart="4dp"
- android:onClick="@{() -> config.addPeer()}"
- android:text="@string/add_peer" />
- </LinearLayout>
- </ScrollView>
-</layout>
diff --git a/app/src/main/res/layout/config_editor_fragment.xml b/app/src/main/res/layout/config_editor_fragment.xml
new file mode 100644
index 00000000..c0895656
--- /dev/null
+++ b/app/src/main/res/layout/config_editor_fragment.xml
@@ -0,0 +1,233 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <data>
+
+ <import type="com.wireguard.android.util.ClipboardUtils" />
+
+ <import type="com.wireguard.android.widget.KeyInputFilter" />
+
+ <import type="com.wireguard.android.widget.NameInputFilter" />
+
+ <import type="com.wireguard.config.Peer" />
+
+ <variable
+ name="config"
+ type="com.wireguard.config.Config" />
+
+ <variable
+ name="name"
+ type="android.databinding.ObservableField&lt;String&gt;" />
+ </data>
+
+ <com.commonsware.cwac.crossport.design.widget.CoordinatorLayout
+ android:id="@+id/main_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?android:attr/colorBackground">
+
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginTop="8dp"
+ android:background="?android:attr/colorBackground"
+ android:elevation="2dp"
+ android:padding="8dp">
+
+ <TextView
+ android:id="@+id/interface_title"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:text="@string/iface" />
+
+ <TextView
+ android:id="@+id/interface_name_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_title"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/interface_name_text"
+ android:text="@string/name" />
+
+ <EditText
+ android:id="@+id/interface_name_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_label"
+ android:inputType="textNoSuggestions"
+ android:text="@={name}"
+ app:filter="@{NameInputFilter.newInstance()}" />
+
+ <TextView
+ android:id="@+id/private_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_text"
+ android:labelFor="@+id/private_key_text"
+ android:text="@string/private_key" />
+
+ <EditText
+ android:id="@+id/private_key_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/private_key_label"
+ android:layout_toStartOf="@+id/generate_private_key_button"
+ android:contentDescription="@string/public_key_description"
+ android:inputType="textVisiblePassword"
+ android:text="@={config.interface.privateKey}"
+ app:filter="@{KeyInputFilter.newInstance()}" />
+
+ <Button
+ android:id="@+id/generate_private_key_button"
+ android:layout_width="96dp"
+ android:layout_height="wrap_content"
+ android:layout_alignBottom="@id/private_key_text"
+ android:layout_alignParentEnd="true"
+ android:layout_below="@+id/private_key_label"
+ android:onClick="@{() -> config.interface.generateKeypair()}"
+ android:text="@string/generate" />
+
+ <TextView
+ android:id="@+id/public_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/private_key_text"
+ android:labelFor="@+id/public_key_text"
+ android:text="@string/public_key" />
+
+ <TextView
+ android:id="@+id/public_key_text"
+ style="?android:attr/editTextStyle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_label"
+ android:ellipsize="end"
+ android:focusable="false"
+ android:hint="@string/hint_generated"
+ android:maxLines="1"
+ android:onClick="@{ClipboardUtils::copyTextView}"
+ android:text="@{config.interface.publicKey}" />
+
+ <TextView
+ android:id="@+id/addresses_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/public_key_text"
+ android:layout_toStartOf="@+id/listen_port_label"
+ android:labelFor="@+id/addresses_text"
+ android:text="@string/addresses" />
+
+ <EditText
+ android:id="@+id/addresses_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/addresses_label"
+ android:layout_toStartOf="@+id/listen_port_text"
+ android:inputType="textNoSuggestions"
+ android:text="@={config.interface.address}" />
+
+ <TextView
+ android:id="@+id/listen_port_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/addresses_label"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:labelFor="@+id/listen_port_text"
+ android:text="@string/listen_port" />
+
+ <EditText
+ android:id="@+id/listen_port_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/addresses_text"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:hint="@string/hint_random"
+ android:inputType="number"
+ android:text="@={config.interface.listenPort}"
+ android:textAlignment="center" />
+
+ <TextView
+ android:id="@+id/dns_servers_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/addresses_text"
+ android:layout_toStartOf="@+id/mtu_label"
+ android:labelFor="@+id/dns_servers_text"
+ android:text="@string/dns_servers" />
+
+ <EditText
+ android:id="@+id/dns_servers_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/dns_servers_label"
+ android:layout_toStartOf="@+id/mtu_text"
+ android:inputType="textNoSuggestions"
+ android:text="@={config.interface.dns}" />
+
+ <TextView
+ android:id="@+id/mtu_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/dns_servers_label"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:labelFor="@+id/mtu_text"
+ android:text="@string/mtu" />
+
+ <EditText
+ android:id="@+id/mtu_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/dns_servers_text"
+ android:layout_alignParentEnd="true"
+ android:layout_alignStart="@+id/generate_private_key_button"
+ android:hint="@string/hint_automatic"
+ android:inputType="number"
+ android:text="@={config.interface.mtu}"
+ android:textAlignment="center" />
+ </RelativeLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:divider="@null"
+ android:orientation="vertical"
+ app:items="@{config.peers}"
+ app:layout="@{@layout/config_editor_peer}"
+ tools:ignore="UselessLeaf" />
+
+ <Button
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="4dp"
+ android:layout_marginStart="4dp"
+ android:onClick="@{() -> config.peers.add(Peer.newInstance())}"
+ android:text="@string/add_peer" />
+ </LinearLayout>
+ </ScrollView>
+ </com.commonsware.cwac.crossport.design.widget.CoordinatorLayout>
+</layout>
diff --git a/app/src/main/res/layout/config_edit_peer.xml b/app/src/main/res/layout/config_editor_peer.xml
index 8e627579..a3a2a9c8 100644
--- a/app/src/main/res/layout/config_edit_peer.xml
+++ b/app/src/main/res/layout/config_editor_peer.xml
@@ -4,7 +4,11 @@
<data>
- <import type="com.wireguard.android.KeyInputFilter" />
+ <import type="com.wireguard.android.widget.KeyInputFilter" />
+
+ <variable
+ name="collection"
+ type="android.databinding.ObservableList&lt;com.wireguard.config.Peer&gt;" />
<variable
name="item"
@@ -41,7 +45,7 @@
android:layout_alignParentTop="true"
android:background="@null"
android:contentDescription="@string/delete"
- android:onClick="@{() -> item.removeSelf()}"
+ android:onClick="@{() -> collection.remove(item)}"
android:src="@drawable/ic_action_delete_black" />
<TextView
diff --git a/app/src/main/res/layout/config_list_fragment.xml b/app/src/main/res/layout/config_list_fragment.xml
deleted file mode 100644
index 627ecb86..00000000
--- a/app/src/main/res/layout/config_list_fragment.xml
+++ /dev/null
@@ -1,54 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<layout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto">
-
- <data>
-
- <!--suppress AndroidDomInspection -->
- <variable
- name="configs"
- type="com.wireguard.android.databinding.ObservableSortedMap&lt;String, com.wireguard.config.Config&gt;" />
- </data>
-
- <RelativeLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <ListView
- android:id="@+id/config_list"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:choiceMode="singleChoice"
- app:items="@{configs}"
- app:layout="@{@layout/config_list_item}" />
-
- <com.getbase.floatingactionbutton.FloatingActionsMenu
- android:id="@+id/add_menu"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentBottom="true"
- android:layout_alignParentEnd="true"
- android:layout_marginBottom="8dp"
- android:layout_marginEnd="8dp"
- app:fab_labelStyle="@style/fab_label"
- app:fab_labelsPosition="left">
-
- <com.getbase.floatingactionbutton.FloatingActionButton
- android:id="@+id/add_from_file"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:fab_icon="@drawable/ic_action_open"
- app:fab_size="mini"
- app:fab_title="@string/add_from_file" />
-
- <com.getbase.floatingactionbutton.FloatingActionButton
- android:id="@+id/add_from_scratch"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:fab_icon="@drawable/ic_action_edit"
- app:fab_size="mini"
- app:fab_title="@string/add_from_scratch" />
- </com.getbase.floatingactionbutton.FloatingActionsMenu>
-
- </RelativeLayout>
-</layout>
diff --git a/app/src/main/res/layout/add_activity.xml b/app/src/main/res/layout/main_activity.xml
index 0f21e2e8..d67e64bc 100644
--- a/app/src/main/res/layout/add_activity.xml
+++ b/app/src/main/res/layout/main_activity.xml
@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/master_fragment"
android:layout_width="match_parent"
- android:layout_height="match_parent"
- tools:ignore="MergeRootFrame" />
+ android:layout_height="match_parent" />
diff --git a/app/src/main/res/layout/not_supported_activity.xml b/app/src/main/res/layout/not_supported_activity.xml
deleted file mode 100644
index ff81e7a4..00000000
--- a/app/src/main/res/layout/not_supported_activity.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<layout xmlns:android="http://schemas.android.com/apk/res/android">
-
- <ScrollView
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <TextView
- android:id="@+id/not_supported_message"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:padding="32dp"
- android:textAppearance="@android:style/TextAppearance.Material.Subhead" />
- </ScrollView>
-</layout>
diff --git a/app/src/main/res/layout/tunnel_detail_fragment.xml b/app/src/main/res/layout/tunnel_detail_fragment.xml
new file mode 100644
index 00000000..e23536a7
--- /dev/null
+++ b/app/src/main/res/layout/tunnel_detail_fragment.xml
@@ -0,0 +1,142 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <data>
+
+ <import type="com.wireguard.android.model.Tunnel.State" />
+
+ <import type="com.wireguard.android.util.ClipboardUtils" />
+
+ <variable
+ name="tunnel"
+ type="com.wireguard.android.model.Tunnel" />
+ </data>
+
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?android:attr/colorBackground">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:layout_marginEnd="8dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginTop="8dp"
+ android:background="?android:attr/colorBackground"
+ android:elevation="2dp"
+ android:padding="8dp">
+
+ <TextView
+ android:id="@+id/interface_title"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:text="@string/iface" />
+
+ <TextView
+ android:id="@+id/interface_name_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_title"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/interface_name_text"
+ android:text="@string/name" />
+
+ <TextView
+ android:id="@+id/interface_name_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_label"
+ android:text="@{tunnel.name}" />
+
+ <TextView
+ android:id="@+id/status_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/interface_name_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/status_text"
+ android:text="@string/status" />
+
+ <TextView
+ android:id="@+id/status_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/status_label"
+ android:layout_toStartOf="@+id/tunnel_switch"
+ android:text="@{tunnel.state.name}" />
+
+ <com.wireguard.android.widget.ToggleSwitch
+ android:id="@+id/tunnel_switch"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignBaseline="@+id/status_text"
+ android:layout_alignParentEnd="true"
+ android:enabled="@{tunnel.state != State.UNKNOWN}"
+ app:checked="@{tunnel.state == State.UP}"
+ app:onBeforeCheckedChanged="@{() -> tunnel.setState(State.TOGGLE)}" />
+
+ <TextView
+ android:id="@+id/last_change_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/status_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/last_change_text"
+ android:text="@string/last_change" />
+
+ <TextView
+ android:id="@+id/last_change_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/last_change_label"
+ android:text="@{tunnel.lastStateChange}" />
+
+ <TextView
+ android:id="@+id/public_key_label"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/last_change_text"
+ android:layout_marginTop="8dp"
+ android:labelFor="@+id/public_key_text"
+ android:text="@string/public_key" />
+
+ <TextView
+ android:id="@+id/public_key_text"
+ style="?android:attr/textAppearanceMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@+id/public_key_label"
+ android:contentDescription="@string/public_key_description"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:onClick="@{ClipboardUtils::copyTextView}"
+ android:text="@{tunnel.config.interface.publicKey}" />
+ </RelativeLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="4dp"
+ android:divider="@null"
+ android:orientation="vertical"
+ app:items="@{tunnel.config.peers}"
+ app:layout="@{@layout/tunnel_detail_peer}"
+ tools:ignore="UselessLeaf" />
+ </LinearLayout>
+ </ScrollView>
+</layout>
diff --git a/app/src/main/res/layout/config_detail_peer.xml b/app/src/main/res/layout/tunnel_detail_peer.xml
index 1bd1b333..0de72dea 100644
--- a/app/src/main/res/layout/config_detail_peer.xml
+++ b/app/src/main/res/layout/tunnel_detail_peer.xml
@@ -4,6 +4,10 @@
<data>
<variable
+ name="collection"
+ type="android.databinding.ObservableList&lt;com.wireguard.config.Peer&gt;" />
+
+ <variable
name="item"
type="com.wireguard.config.Peer" />
</data>
@@ -25,7 +29,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
- android:layout_marginBottom="8dp"
android:text="@string/peer" />
<TextView
@@ -33,6 +36,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/peer_title"
+ android:layout_marginTop="8dp"
android:labelFor="@+id/public_key_text"
android:text="@string/public_key" />
@@ -51,6 +55,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/public_key_text"
+ android:layout_marginTop="8dp"
android:labelFor="@+id/allowed_ips_text"
android:text="@string/allowed_ips" />
@@ -67,6 +72,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/allowed_ips_text"
+ android:layout_marginTop="8dp"
android:labelFor="@+id/endpoint_text"
android:text="@string/endpoint" />
diff --git a/app/src/main/res/layout/tunnel_list_fragment.xml b/app/src/main/res/layout/tunnel_list_fragment.xml
new file mode 100644
index 00000000..e4923d54
--- /dev/null
+++ b/app/src/main/res/layout/tunnel_list_fragment.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto">
+
+ <data>
+
+ <variable
+ name="fragment"
+ type="com.wireguard.android.fragment.TunnelListFragment" />
+
+ <variable
+ name="tunnels"
+ type="com.wireguard.android.model.TunnelCollection" />
+ </data>
+
+ <com.commonsware.cwac.crossport.design.widget.CoordinatorLayout
+ android:id="@+id/main_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?android:attr/colorBackground">
+
+ <ListView
+ android:id="@+id/tunnel_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:choiceMode="multipleChoiceModal"
+ app:items="@{tunnels}"
+ app:layout="@{@layout/tunnel_list_item}" />
+
+ <com.getbase.floatingactionbutton.FloatingActionsMenu
+ android:id="@+id/create_menu"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom|end"
+ android:layout_margin="8dp"
+ app:fab_labelStyle="@style/fab_label"
+ app:fab_labelsPosition="left"
+ app:layout_dodgeInsetEdges="bottom">
+
+ <com.getbase.floatingactionbutton.FloatingActionButton
+ android:id="@+id/create_empty"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:onClick="@{fragment::onRequestCreateConfig}"
+ app:fab_icon="@drawable/ic_action_edit"
+ app:fab_size="mini"
+ app:fab_title="@string/create_empty" />
+
+ <com.getbase.floatingactionbutton.FloatingActionButton
+ android:id="@+id/create_from_file"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:onClick="@{fragment::onRequestImportConfig}"
+ app:fab_icon="@drawable/ic_action_open"
+ app:fab_size="mini"
+ app:fab_title="@string/create_from_file" />
+ </com.getbase.floatingactionbutton.FloatingActionsMenu>
+ </com.commonsware.cwac.crossport.design.widget.CoordinatorLayout>
+</layout>
diff --git a/app/src/main/res/layout/config_list_item.xml b/app/src/main/res/layout/tunnel_list_item.xml
index 90e696a4..c8706546 100644
--- a/app/src/main/res/layout/config_list_item.xml
+++ b/app/src/main/res/layout/tunnel_list_item.xml
@@ -4,9 +4,11 @@
<data>
- <import type="android.graphics.Typeface" />
+ <import type="com.wireguard.android.model.Tunnel.State" />
- <import type="com.wireguard.android.backends.VpnService" />
+ <variable
+ name="collection"
+ type="com.wireguard.android.model.TunnelCollection" />
<variable
name="key"
@@ -14,7 +16,7 @@
<variable
name="item"
- type="com.wireguard.config.Config" />
+ type="com.wireguard.android.model.Tunnel" />
</data>
<RelativeLayout
@@ -25,24 +27,25 @@
android:padding="16dp">
<TextView
- android:id="@+id/config_name"
+ android:id="@+id/tunnel_name"
style="?android:attr/textAppearanceMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
- android:layout_toStartOf="@+id/config_switch"
+ android:layout_alignParentTop="true"
+ android:layout_toStartOf="@+id/tunnel_switch"
android:ellipsize="end"
android:maxLines="1"
- android:text="@{key}"
- android:textStyle="@{item.primary ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT}" />
+ android:text="@{key}" />
<com.wireguard.android.widget.ToggleSwitch
- android:id="@+id/config_switch"
+ android:id="@+id/tunnel_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_alignBaseline="@+id/config_name"
+ android:layout_alignBaseline="@+id/tunnel_name"
android:layout_alignParentEnd="true"
- app:checked="@{item.enabled}"
- app:onBeforeCheckedChanged="@{(v, checked) -> checked ? VpnService.instance.enable(item.name) : VpnService.instance.disable(item.name)}" />
+ android:enabled="@{item.state != State.UNKNOWN}"
+ app:checked="@{item.state == State.UP}"
+ app:onBeforeCheckedChanged="@{() -> item.setState(State.TOGGLE)}" />
</RelativeLayout>
</layout>
diff --git a/app/src/main/res/menu/config_edit.xml b/app/src/main/res/menu/config_editor.xml
index 44e719c7..44e719c7 100644
--- a/app/src/main/res/menu/config_edit.xml
+++ b/app/src/main/res/menu/config_editor.xml
diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main_activity.xml
index 462558ec..462558ec 100644
--- a/app/src/main/res/menu/main.xml
+++ b/app/src/main/res/menu/main_activity.xml
diff --git a/app/src/main/res/menu/config_detail.xml b/app/src/main/res/menu/tunnel_detail.xml
index c00c1603..c00c1603 100644
--- a/app/src/main/res/menu/config_detail.xml
+++ b/app/src/main/res/menu/tunnel_detail.xml
diff --git a/app/src/main/res/menu/config_list_delete.xml b/app/src/main/res/menu/tunnel_list_action_mode.xml
index 7896d522..7896d522 100644
--- a/app/src/main/res/menu/config_list_delete.xml
+++ b/app/src/main/res/menu/tunnel_list_action_mode.xml
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 02d2ac4c..2b5d49c2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -5,8 +5,8 @@
<item quantity="other">%d configurations selected</item>
</plurals>
<string name="add_activity_title">New WireGuard configuration</string>
- <string name="add_from_file">Add from file</string>
- <string name="add_from_scratch">Add from scratch</string>
+ <string name="create_from_file">Add from file</string>
+ <string name="create_empty">Add from scratch</string>
<string name="add_peer">Add peer</string>
<string name="addresses">Addresses</string>
<string name="allowed_ips">Allowed IPs</string>
@@ -57,7 +57,7 @@
<string name="private_key">Private key</string>
<string name="public_key">Public key</string>
<string name="public_key_copied_message">Public key copied to clipboard</string>
- <string name="public_key_description">WireGuard public key</string>
+ <string name="public_key_description">Public key</string>
<string name="restore_on_boot">Restore on boot</string>
<string name="restore_on_boot_summary">Restore previously enabled configurations on boot</string>
<string name="install_cmd_line_tools">Install command line tools</string>
@@ -70,4 +70,6 @@
<string name="settings">Settings</string>
<string name="status">Status</string>
<string name="toggle">Toggle</string>
+ <string name="last_change">Last change</string>
+ <string name="never">never</string>
</resources>
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 012e08fa..1693839b 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
- <com.wireguard.android.ConfigListPreference
+ <com.wireguard.android.preference.TunnelListPreference
android:key="primary_config"
android:summary="@string/primary_config_summary"
android:title="@string/primary_config" />