Serviceless rewrite, part 1

Signed-off-by: Samuel Holland <samuel@sholland.org>
This commit is contained in:
Samuel Holland 2018-01-01 02:06:37 -06:00
parent 4c0869393e
commit 609194fae2
68 changed files with 2564 additions and 2296 deletions

View File

@ -31,6 +31,20 @@ android {
}
dependencies {
annotationProcessor 'com.google.dagger:dagger-compiler:2.14.1'
implementation 'com.android.databinding:library:1.3.3'
implementation 'com.android.support:support-annotations:27.0.2'
implementation 'com.commonsware.cwac:crossport:0.2.1'
implementation 'com.gabrielittner.threetenbp:lazythreetenbp:0.2.0'
implementation 'com.getbase:floatingactionbutton:1.10.1'
implementation 'com.google.dagger:dagger:2.14.1'
implementation 'net.sourceforge.streamsupport:android-retrofuture:1.6.0'
implementation 'net.sourceforge.streamsupport:android-retrostreams:1.6.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
}
repositories {
maven {
url "https://s3.amazonaws.com/repo.commonsware.com"
}
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}
}

View File

@ -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());
}
}
}

View File

@ -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();
}
}
}

View File

@ -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;
public ToolsInstaller(Preference installTools) {
this.installTools = installTools;
installTools.setEnabled(false);
installTools.setSummary(installTools.getContext().getString(R.string.install_cmd_line_tools_progress));
}
private static final String[][] libraryNamedExecutables = {
{ "libwg.so", "wg" },
{ "libwg-quick.so", "wg-quick" }
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"}
};
private final Context context;
private final Preference preference;
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);
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}

View File

@ -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;
}
});
}
}

View File

@ -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.
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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.
}

View File

@ -12,15 +12,9 @@ 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) {
@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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 {
}
}

View File

@ -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> {
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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");
}
}

View File

@ -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) {

View File

@ -1,4 +1,4 @@
package com.wireguard.android;
package com.wireguard.android.widget;
import android.text.InputFilter;
import android.text.SpannableStringBuilder;

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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();
@ -48,31 +45,6 @@ public class Interface extends BaseObservable
setPrivateKey(in.readString());
}
@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);
}

View File

@ -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;

View File

@ -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>

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,233 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="com.wireguard.android.util.ClipboardUtils" />
<import type="com.wireguard.android.widget.KeyInputFilter" />
<import type="com.wireguard.android.widget.NameInputFilter" />
<import type="com.wireguard.config.Peer" />
<variable
name="config"
type="com.wireguard.config.Config" />
<variable
name="name"
type="android.databinding.ObservableField&lt;String&gt;" />
</data>
<com.commonsware.cwac.crossport.design.widget.CoordinatorLayout
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:background="?android:attr/colorBackground"
android:elevation="2dp"
android:padding="8dp">
<TextView
android:id="@+id/interface_title"
style="?android:attr/textAppearanceMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:text="@string/iface" />
<TextView
android:id="@+id/interface_name_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/interface_title"
android:layout_marginTop="8dp"
android:labelFor="@+id/interface_name_text"
android:text="@string/name" />
<EditText
android:id="@+id/interface_name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/interface_name_label"
android:inputType="textNoSuggestions"
android:text="@={name}"
app:filter="@{NameInputFilter.newInstance()}" />
<TextView
android:id="@+id/private_key_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/interface_name_text"
android:labelFor="@+id/private_key_text"
android:text="@string/private_key" />
<EditText
android:id="@+id/private_key_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_below="@+id/private_key_label"
android:layout_toStartOf="@+id/generate_private_key_button"
android:contentDescription="@string/public_key_description"
android:inputType="textVisiblePassword"
android:text="@={config.interface.privateKey}"
app:filter="@{KeyInputFilter.newInstance()}" />
<Button
android:id="@+id/generate_private_key_button"
android:layout_width="96dp"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/private_key_text"
android:layout_alignParentEnd="true"
android:layout_below="@+id/private_key_label"
android:onClick="@{() -> config.interface.generateKeypair()}"
android:text="@string/generate" />
<TextView
android:id="@+id/public_key_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/private_key_text"
android:labelFor="@+id/public_key_text"
android:text="@string/public_key" />
<TextView
android:id="@+id/public_key_text"
style="?android:attr/editTextStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/public_key_label"
android:ellipsize="end"
android:focusable="false"
android:hint="@string/hint_generated"
android:maxLines="1"
android:onClick="@{ClipboardUtils::copyTextView}"
android:text="@{config.interface.publicKey}" />
<TextView
android:id="@+id/addresses_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_below="@+id/public_key_text"
android:layout_toStartOf="@+id/listen_port_label"
android:labelFor="@+id/addresses_text"
android:text="@string/addresses" />
<EditText
android:id="@+id/addresses_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_below="@+id/addresses_label"
android:layout_toStartOf="@+id/listen_port_text"
android:inputType="textNoSuggestions"
android:text="@={config.interface.address}" />
<TextView
android:id="@+id/listen_port_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/addresses_label"
android:layout_alignParentEnd="true"
android:layout_alignStart="@+id/generate_private_key_button"
android:labelFor="@+id/listen_port_text"
android:text="@string/listen_port" />
<EditText
android:id="@+id/listen_port_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/addresses_text"
android:layout_alignParentEnd="true"
android:layout_alignStart="@+id/generate_private_key_button"
android:hint="@string/hint_random"
android:inputType="number"
android:text="@={config.interface.listenPort}"
android:textAlignment="center" />
<TextView
android:id="@+id/dns_servers_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_below="@+id/addresses_text"
android:layout_toStartOf="@+id/mtu_label"
android:labelFor="@+id/dns_servers_text"
android:text="@string/dns_servers" />
<EditText
android:id="@+id/dns_servers_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_below="@+id/dns_servers_label"
android:layout_toStartOf="@+id/mtu_text"
android:inputType="textNoSuggestions"
android:text="@={config.interface.dns}" />
<TextView
android:id="@+id/mtu_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/dns_servers_label"
android:layout_alignParentEnd="true"
android:layout_alignStart="@+id/generate_private_key_button"
android:labelFor="@+id/mtu_text"
android:text="@string/mtu" />
<EditText
android:id="@+id/mtu_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@+id/dns_servers_text"
android:layout_alignParentEnd="true"
android:layout_alignStart="@+id/generate_private_key_button"
android:hint="@string/hint_automatic"
android:inputType="number"
android:text="@={config.interface.mtu}"
android:textAlignment="center" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@null"
android:orientation="vertical"
app:items="@{config.peers}"
app:layout="@{@layout/config_editor_peer}"
tools:ignore="UselessLeaf" />
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:onClick="@{() -> config.peers.add(Peer.newInstance())}"
android:text="@string/add_peer" />
</LinearLayout>
</ScrollView>
</com.commonsware.cwac.crossport.design.widget.CoordinatorLayout>
</layout>

View File

@ -4,7 +4,11 @@
<data>
<import type="com.wireguard.android.KeyInputFilter" />
<import type="com.wireguard.android.widget.KeyInputFilter" />
<variable
name="collection"
type="android.databinding.ObservableList&lt;com.wireguard.config.Peer&gt;" />
<variable
name="item"
@ -41,7 +45,7 @@
android:layout_alignParentTop="true"
android:background="@null"
android:contentDescription="@string/delete"
android:onClick="@{() -> item.removeSelf()}"
android:onClick="@{() -> collection.remove(item)}"
android:src="@drawable/ic_action_delete_black" />
<TextView

View File

@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<!--suppress AndroidDomInspection -->
<variable
name="configs"
type="com.wireguard.android.databinding.ObservableSortedMap&lt;String, com.wireguard.config.Config&gt;" />
</data>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/config_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:choiceMode="singleChoice"
app:items="@{configs}"
app:layout="@{@layout/config_list_item}" />
<com.getbase.floatingactionbutton.FloatingActionsMenu
android:id="@+id/add_menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
app:fab_labelStyle="@style/fab_label"
app:fab_labelsPosition="left">
<com.getbase.floatingactionbutton.FloatingActionButton
android:id="@+id/add_from_file"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:fab_icon="@drawable/ic_action_open"
app:fab_size="mini"
app:fab_title="@string/add_from_file" />
<com.getbase.floatingactionbutton.FloatingActionButton
android:id="@+id/add_from_scratch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:fab_icon="@drawable/ic_action_edit"
app:fab_size="mini"
app:fab_title="@string/add_from_scratch" />
</com.getbase.floatingactionbutton.FloatingActionsMenu>
</RelativeLayout>
</layout>

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -3,6 +3,10 @@
<data>
<variable
name="collection"
type="android.databinding.ObservableList&lt;com.wireguard.config.Peer&gt;" />
<variable
name="item"
type="com.wireguard.config.Peer" />
@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" />