Signed-off-by: Samuel Holland <samuel@sholland.org>master
@@ -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" | |||
} | |||
} |
@@ -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> |
@@ -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); | |||
} | |||
} |
@@ -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; | |||
} | |||
} | |||
} |
@@ -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(); | |||
} | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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)); | |||
} | |||
} |
@@ -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); | |||
} | |||
} | |||
} |
@@ -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(); | |||
} | |||
} | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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; | |||
} | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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(); | |||
} | |||
} | |||
} |
@@ -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()); | |||
} | |||
} | |||
} |
@@ -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(); | |||
} | |||
} | |||
} |
@@ -1,23 +1,34 @@ | |||
package com.wireguard.android; | |||
package com.wireguard.android.activity; | |||
import android.app.Activity; | |||
import android.app.FragmentTransaction; | |||
import android.app.Fragment; | |||
import android.content.Context; | |||
import android.os.AsyncTask; | |||
import android.os.Bundle; | |||
import android.preference.Preference; | |||
import android.preference.PreferenceFragment; | |||
import com.wireguard.android.backends.RootShell; | |||
import com.wireguard.android.R; | |||
import com.wireguard.android.preference.TunnelListPreference; | |||
import com.wireguard.android.util.RootShell; | |||
/** | |||
* Interface for changing application-global persistent settings. | |||
*/ | |||
public class SettingsActivity extends Activity { | |||
public static final String KEY_SHOW_QUICK_TILE_SETTINGS = "show_quick_tile_settings"; | |||
@Override | |||
protected void onCreate(final Bundle savedInstanceState) { | |||
super.onCreate(savedInstanceState); | |||
final FragmentTransaction transaction = getFragmentManager().beginTransaction(); | |||
final SettingsFragment fragment = new SettingsFragment(); | |||
fragment.setArguments(getIntent().getExtras()); | |||
transaction.replace(android.R.id.content, fragment).commit(); | |||
if (getFragmentManager().findFragmentById(android.R.id.content) == null) { | |||
final Fragment fragment = new SettingsFragment(); | |||
fragment.setArguments(getIntent().getExtras()); | |||
getFragmentManager().beginTransaction() | |||
.add(android.R.id.content, fragment) | |||
.commit(); | |||
} | |||
} | |||
public static class SettingsFragment extends PreferenceFragment { | |||
@@ -25,44 +36,42 @@ public class SettingsActivity extends Activity { | |||
public void onCreate(final Bundle savedInstanceState) { | |||
super.onCreate(savedInstanceState); | |||
addPreferencesFromResource(R.xml.preferences); | |||
if (getArguments() != null && getArguments().getBoolean("showQuickTile")) | |||
((ConfigListPreference) findPreference("primary_config")).show(); | |||
final Preference installTools = findPreference("install_cmd_line_tools"); | |||
installTools.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { | |||
public boolean onPreferenceClick(Preference preference) { | |||
new ToolsInstaller(installTools).execute(); | |||
return true; | |||
} | |||
installTools.setOnPreferenceClickListener(preference -> { | |||
new ToolsInstaller(preference).execute(); | |||
return true; | |||
}); | |||
if (getArguments() != null && getArguments().getBoolean(KEY_SHOW_QUICK_TILE_SETTINGS)) | |||
((TunnelListPreference) findPreference("primary_config")).show(); | |||
} | |||
} | |||
private static class ToolsInstaller extends AsyncTask<Void, Void, Integer> { | |||
Preference installTools; | |||
private static final class ToolsInstaller extends AsyncTask<Void, Void, Integer> { | |||
private static final String[][] LIBRARY_NAMED_EXECUTABLES = { | |||
{"libwg.so", "wg"}, | |||
{"libwg-quick.so", "wg-quick"} | |||
}; | |||
public ToolsInstaller(Preference installTools) { | |||
this.installTools = installTools; | |||
installTools.setEnabled(false); | |||
installTools.setSummary(installTools.getContext().getString(R.string.install_cmd_line_tools_progress)); | |||
} | |||
private final Context context; | |||
private final Preference preference; | |||
private static final String[][] libraryNamedExecutables = { | |||
{ "libwg.so", "wg" }, | |||
{ "libwg-quick.so", "wg-quick" } | |||
}; | |||
private ToolsInstaller(final Preference preference) { | |||
context = preference.getContext(); | |||
this.preference = preference; | |||
preference.setEnabled(false); | |||
preference.setSummary(context.getString(R.string.install_cmd_line_tools_progress)); | |||
} | |||
@Override | |||
protected Integer doInBackground(final Void... voids) { | |||
final Context context = installTools.getContext(); | |||
final String libDir = context.getApplicationInfo().nativeLibraryDir; | |||
final StringBuilder cmd = new StringBuilder(); | |||
cmd.append("set -ex;"); | |||
for (final String[] libraryNamedExecutable : libraryNamedExecutables) { | |||
final String arg1 = "'" + libDir + "/" + libraryNamedExecutable[0] + "'"; | |||
final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + "'"; | |||
for (final String[] libraryNamedExecutable : LIBRARY_NAMED_EXECUTABLES) { | |||
final String arg1 = '\'' + libDir + '/' + libraryNamedExecutable[0] + '\''; | |||
final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + '\''; | |||
cmd.append(String.format("cmp -s %s %s && ", arg1, arg2)); | |||
} | |||
@@ -71,9 +80,9 @@ public class SettingsActivity extends Activity { | |||
cmd.append("trap 'mount -o ro,remount /system' EXIT;"); | |||
cmd.append("mount -o rw,remount /system;"); | |||
for (final String[] libraryNamedExecutable : libraryNamedExecutables) { | |||
final String arg1 = "'" + libDir + "/" + libraryNamedExecutable[0] + "'"; | |||
final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + "'"; | |||
for (final String[] libraryNamedExecutable : LIBRARY_NAMED_EXECUTABLES) { | |||
final String arg1 = '\'' + libDir + '/' + libraryNamedExecutable[0] + '\''; | |||
final String arg2 = "'/system/xbin/" + libraryNamedExecutable[1] + '\''; | |||
cmd.append(String.format("cp %s %s; chmod 755 %s;", arg1, arg2, arg2)); | |||
} | |||
@@ -82,8 +91,7 @@ public class SettingsActivity extends Activity { | |||
@Override | |||
protected void onPostExecute(final Integer ret) { | |||
final Context context = installTools.getContext(); | |||
String status; | |||
final String status; | |||
switch (ret) { | |||
case 0: | |||
@@ -96,8 +104,8 @@ public class SettingsActivity extends Activity { | |||
status = context.getString(R.string.install_cmd_line_tools_failure); | |||
break; | |||
} | |||
installTools.setSummary(status); | |||
installTools.setEnabled(true); | |||
preference.setSummary(status); | |||
preference.setEnabled(true); | |||
} | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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); | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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(); | |||
} | |||
} | |||
} |
@@ -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); | |||
} |
@@ -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; | |||
} | |||
}); | |||
} | |||
} |
@@ -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. | |||
} | |||
} |
@@ -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; | |||
@@ -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; | |||
@@ -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; | |||
@@ -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. | |||
} |
@@ -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; | |||
} | |||
@@ -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); | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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; | |||
} | |||
} | |||
} |
@@ -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 { | |||
} | |||
} |
@@ -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> { | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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(); | |||
} | |||
} |
@@ -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"); | |||
} | |||
} |
@@ -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) { |
@@ -1,4 +1,4 @@ | |||
package com.wireguard.android; | |||
package com.wireguard.android.widget; | |||
import android.text.InputFilter; | |||
import android.text.SpannableStringBuilder; |
@@ -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) |
@@ -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); | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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); | |||
} |
@@ -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); | |||
} | |||
@@ -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; | |||
@@ -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> |
@@ -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" /> |
@@ -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> |
@@ -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> |
@@ -0,0 +1,233 @@ | |||
<?xml version="1.0" encoding="utf-8"?> | |||
<layout xmlns:android="http://schemas.android.com/apk/res/android" | |||
xmlns:app="http://schemas.android.com/apk/res-auto" | |||
xmlns:tools="http://schemas.android.com/tools"> | |||
<data> | |||
<import type="com.wireguard.android.util.ClipboardUtils" /> | |||
<import type="com.wireguard.android.widget.KeyInputFilter" /> | |||
<import type="com.wireguard.android.widget.NameInputFilter" /> | |||
<import type="com.wireguard.config.Peer" /> | |||
<variable | |||
name="config" | |||
type="com.wireguard.config.Config" /> | |||
<variable | |||
name="name" | |||
type="android.databinding.ObservableField<String>" /> | |||
</data> | |||
<com.commonsware.cwac.crossport.design.widget.CoordinatorLayout | |||
android:id="@+id/main_container" | |||
android:layout_width="match_parent" | |||
android:layout_height="match_parent" | |||
android:background="?android:attr/colorBackground"> | |||
<ScrollView | |||
android:layout_width="match_parent" | |||
android:layout_height="match_parent"> | |||
<LinearLayout | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:orientation="vertical"> | |||
<RelativeLayout | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_marginBottom="4dp" | |||
android:layout_marginEnd="8dp" | |||
android:layout_marginStart="8dp" | |||
android:layout_marginTop="8dp" | |||
android:background="?android:attr/colorBackground" | |||
android:elevation="2dp" | |||
android:padding="8dp"> | |||
<TextView | |||
android:id="@+id/interface_title" | |||
style="?android:attr/textAppearanceMedium" | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentTop="true" | |||
android:text="@string/iface" /> | |||
<TextView | |||
android:id="@+id/interface_name_label" | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_below="@+id/interface_title" | |||
android:layout_marginTop="8dp" | |||
android:labelFor="@+id/interface_name_text" | |||
android:text="@string/name" /> | |||
<EditText | |||
android:id="@+id/interface_name_text" | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_below="@+id/interface_name_label" | |||
android:inputType="textNoSuggestions" | |||
android:text="@={name}" | |||
app:filter="@{NameInputFilter.newInstance()}" /> | |||
<TextView | |||
android:id="@+id/private_key_label" | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_below="@+id/interface_name_text" | |||
android:labelFor="@+id/private_key_text" | |||
android:text="@string/private_key" /> | |||
<EditText | |||
android:id="@+id/private_key_text" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentStart="true" | |||
android:layout_below="@+id/private_key_label" | |||
android:layout_toStartOf="@+id/generate_private_key_button" | |||
android:contentDescription="@string/public_key_description" | |||
android:inputType="textVisiblePassword" | |||
android:text="@={config.interface.privateKey}" | |||
app:filter="@{KeyInputFilter.newInstance()}" /> | |||
<Button | |||
android:id="@+id/generate_private_key_button" | |||
android:layout_width="96dp" | |||
android:layout_height="wrap_content" | |||
android:layout_alignBottom="@id/private_key_text" | |||
android:layout_alignParentEnd="true" | |||
android:layout_below="@+id/private_key_label" | |||
android:onClick="@{() -> config.interface.generateKeypair()}" | |||
android:text="@string/generate" /> | |||
<TextView | |||
android:id="@+id/public_key_label" | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_below="@+id/private_key_text" | |||
android:labelFor="@+id/public_key_text" | |||
android:text="@string/public_key" /> | |||
<TextView | |||
android:id="@+id/public_key_text" | |||
style="?android:attr/editTextStyle" | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_below="@+id/public_key_label" | |||
android:ellipsize="end" | |||
android:focusable="false" | |||
android:hint="@string/hint_generated" | |||
android:maxLines="1" | |||
android:onClick="@{ClipboardUtils::copyTextView}" | |||
android:text="@{config.interface.publicKey}" /> | |||
<TextView | |||
android:id="@+id/addresses_label" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentStart="true" | |||
android:layout_below="@+id/public_key_text" | |||
android:layout_toStartOf="@+id/listen_port_label" | |||
android:labelFor="@+id/addresses_text" | |||
android:text="@string/addresses" /> | |||
<EditText | |||
android:id="@+id/addresses_text" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentStart="true" | |||
android:layout_below="@+id/addresses_label" | |||
android:layout_toStartOf="@+id/listen_port_text" | |||
android:inputType="textNoSuggestions" | |||
android:text="@={config.interface.address}" /> | |||
<TextView | |||
android:id="@+id/listen_port_label" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignBaseline="@+id/addresses_label" | |||
android:layout_alignParentEnd="true" | |||
android:layout_alignStart="@+id/generate_private_key_button" | |||
android:labelFor="@+id/listen_port_text" | |||
android:text="@string/listen_port" /> | |||
<EditText | |||
android:id="@+id/listen_port_text" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignBaseline="@+id/addresses_text" | |||
android:layout_alignParentEnd="true" | |||
android:layout_alignStart="@+id/generate_private_key_button" | |||
android:hint="@string/hint_random" | |||
android:inputType="number" | |||
android:text="@={config.interface.listenPort}" | |||
android:textAlignment="center" /> | |||
<TextView | |||
android:id="@+id/dns_servers_label" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentStart="true" | |||
android:layout_below="@+id/addresses_text" | |||
android:layout_toStartOf="@+id/mtu_label" | |||
android:labelFor="@+id/dns_servers_text" | |||
android:text="@string/dns_servers" /> | |||
<EditText | |||
android:id="@+id/dns_servers_text" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentStart="true" | |||
android:layout_below="@+id/dns_servers_label" | |||
android:layout_toStartOf="@+id/mtu_text" | |||
android:inputType="textNoSuggestions" | |||
android:text="@={config.interface.dns}" /> | |||
<TextView | |||
android:id="@+id/mtu_label" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignBaseline="@+id/dns_servers_label" | |||
android:layout_alignParentEnd="true" | |||
android:layout_alignStart="@+id/generate_private_key_button" | |||
android:labelFor="@+id/mtu_text" | |||
android:text="@string/mtu" /> | |||
<EditText | |||
android:id="@+id/mtu_text" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignBaseline="@+id/dns_servers_text" | |||
android:layout_alignParentEnd="true" | |||
android:layout_alignStart="@+id/generate_private_key_button" | |||
android:hint="@string/hint_automatic" | |||
android:inputType="number" | |||
android:text="@={config.interface.mtu}" | |||
android:textAlignment="center" /> | |||
</RelativeLayout> | |||
<LinearLayout | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:divider="@null" | |||
android:orientation="vertical" | |||
app:items="@{config.peers}" | |||
app:layout="@{@layout/config_editor_peer}" | |||
tools:ignore="UselessLeaf" /> | |||
<Button | |||
android:layout_width="match_parent" | |||
android:layout_height="wrap_content" | |||
android:layout_marginBottom="4dp" | |||
android:layout_marginEnd="4dp" | |||
android:layout_marginStart="4dp" | |||
android:onClick="@{() -> config.peers.add(Peer.newInstance())}" | |||
android:text="@string/add_peer" /> | |||
</LinearLayout> | |||
</ScrollView> | |||
</com.commonsware.cwac.crossport.design.widget.CoordinatorLayout> | |||
</layout> |
@@ -4,7 +4,11 @@ | |||
<data> | |||
<import type="com.wireguard.android.KeyInputFilter" /> | |||
<import type="com.wireguard.android.widget.KeyInputFilter" /> | |||
<variable | |||
name="collection" | |||
type="android.databinding.ObservableList<com.wireguard.config.Peer>" /> | |||
<variable | |||
name="item" | |||
@@ -41,7 +45,7 @@ | |||
android:layout_alignParentTop="true" | |||
android:background="@null" | |||
android:contentDescription="@string/delete" | |||
android:onClick="@{() -> item.removeSelf()}" | |||
android:onClick="@{() -> collection.remove(item)}" | |||
android:src="@drawable/ic_action_delete_black" /> | |||
<TextView |
@@ -1,54 +0,0 @@ | |||
<?xml version="1.0" encoding="utf-8"?> | |||
<layout xmlns:android="http://schemas.android.com/apk/res/android" | |||
xmlns:app="http://schemas.android.com/apk/res-auto"> | |||
<data> | |||
<!--suppress AndroidDomInspection --> | |||
<variable | |||
name="configs" | |||
type="com.wireguard.android.databinding.ObservableSortedMap<String, com.wireguard.config.Config>" /> | |||
</data> | |||
<RelativeLayout | |||
android:layout_width="match_parent" | |||
android:layout_height="match_parent"> | |||
<ListView | |||
android:id="@+id/config_list" | |||
android:layout_width="match_parent" | |||
android:layout_height="match_parent" | |||
android:choiceMode="singleChoice" | |||
app:items="@{configs}" | |||
app:layout="@{@layout/config_list_item}" /> | |||
<com.getbase.floatingactionbutton.FloatingActionsMenu | |||
android:id="@+id/add_menu" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
android:layout_alignParentBottom="true" | |||
android:layout_alignParentEnd="true" | |||
android:layout_marginBottom="8dp" | |||
android:layout_marginEnd="8dp" | |||
app:fab_labelStyle="@style/fab_label" | |||
app:fab_labelsPosition="left"> | |||
<com.getbase.floatingactionbutton.FloatingActionButton | |||
android:id="@+id/add_from_file" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
app:fab_icon="@drawable/ic_action_open" | |||
app:fab_size="mini" | |||
app:fab_title="@string/add_from_file" /> | |||
<com.getbase.floatingactionbutton.FloatingActionButton | |||
android:id="@+id/add_from_scratch" | |||
android:layout_width="wrap_content" | |||
android:layout_height="wrap_content" | |||
app:fab_icon="@drawable/ic_action_edit" | |||
app:fab_size="mini" | |||
app:fab_title="@string/add_from_scratch" /> | |||
</com.getbase.floatingactionbutton.FloatingActionsMenu> | |||
</RelativeLayout> | |||
</layout> |
@@ -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" /> |
@@ -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> |
@@ -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> |
@@ -3,6 +3,10 @@ | |||
<data> | |||
<variable | |||
name="collection" | |||
type="android.databinding.ObservableList<com.wireguard.config.Peer>" /> | |||
<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" /> | |||
@@ -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> |
@@ -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> |
@@ -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> |
@@ -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" /> | |||