Serviceless rewrite, part 1

Signed-off-by: Samuel Holland <samuel@sholland.org>
This commit is contained in:
Samuel Holland
2018-01-06 04:09:29 -06:00
parent 4c0869393e
commit 609194fae2
68 changed files with 2564 additions and 2296 deletions
@@ -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;
}
}
}