diff options
Diffstat (limited to 'app/src')
67 files changed, 2549 insertions, 2295 deletions
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<String>" /> + </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<com.wireguard.config.Peer>" /> <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<String, com.wireguard.config.Config>" /> - </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<com.wireguard.config.Peer>" /> + + <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" /> |