diff options
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java')
-rw-r--r-- | ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java | 449 |
1 files changed, 449 insertions, 0 deletions
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java new file mode 100644 index 00000000..21618e60 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java @@ -0,0 +1,449 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.material.snackbar.Snackbar; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; +import com.wireguard.android.Application; +import com.wireguard.android.R; +import com.wireguard.android.activity.TunnelCreatorActivity; +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter; +import com.wireguard.android.databinding.TunnelListFragmentBinding; +import com.wireguard.android.databinding.TunnelListItemBinding; +import com.wireguard.android.model.ObservableTunnel; +import com.wireguard.android.ui.EdgeToEdge; +import com.wireguard.android.util.ErrorMessages; +import com.wireguard.android.widget.MultiselectableRelativeLayout; +import com.wireguard.config.BadConfigException; +import com.wireguard.config.Config; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import java9.util.concurrent.CompletableFuture; +import java9.util.stream.StreamSupport; + +/** + * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels. + */ + +public class TunnelListFragment extends BaseFragment { + public static final int REQUEST_IMPORT = 1; + private static final int REQUEST_TARGET_FRAGMENT = 2; + private static final String TAG = "WireGuard/" + TunnelListFragment.class.getSimpleName(); + + private final ActionModeListener actionModeListener = new ActionModeListener(); + @Nullable private ActionMode actionMode; + @Nullable private TunnelListFragmentBinding binding; + + private void importTunnel(@NonNull final String configText) { + try { + // Ensure the config text is parseable before proceeding… + Config.parse(new ByteArrayInputStream(configText.getBytes(StandardCharsets.UTF_8))); + + // Config text is valid, now create the tunnel… + ConfigNamingDialogFragment.newInstance(configText).show(getParentFragmentManager(), null); + } catch (final BadConfigException | IOException e) { + onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(e)); + } + } + + private void importTunnel(@Nullable final Uri uri) { + final Activity activity = getActivity(); + if (activity == null || uri == null) + return; + final ContentResolver contentResolver = activity.getContentResolver(); + + final Collection<CompletableFuture<ObservableTunnel>> futureTunnels = new ArrayList<>(); + final List<Throwable> throwables = new ArrayList<>(); + Application.getAsyncWorker().supplyAsync(() -> { + final String[] columns = {OpenableColumns.DISPLAY_NAME}; + String name = null; + try (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()); + int idx = name.lastIndexOf('/'); + if (idx >= 0) { + if (idx >= name.length() - 1) + throw new IllegalArgumentException(getResources().getString(R.string.illegal_filename_error, name)); + name = name.substring(idx + 1); + } + boolean isZip = name.toLowerCase(Locale.ENGLISH).endsWith(".zip"); + if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf")) + name = name.substring(0, name.length() - ".conf".length()); + else if (!isZip) + throw new IllegalArgumentException(getResources().getString(R.string.bad_extension_error)); + + if (isZip) { + try (ZipInputStream zip = new ZipInputStream(contentResolver.openInputStream(uri)); + BufferedReader reader = new BufferedReader(new InputStreamReader(zip))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) + continue; + name = entry.getName(); + idx = name.lastIndexOf('/'); + if (idx >= 0) { + if (idx >= name.length() - 1) + continue; + name = name.substring(name.lastIndexOf('/') + 1); + } + if (name.toLowerCase(Locale.ENGLISH).endsWith(".conf")) + name = name.substring(0, name.length() - ".conf".length()); + else + continue; + Config config = null; + try { + config = Config.parse(reader); + } catch (Exception e) { + throwables.add(e); + } + if (config != null) + futureTunnels.add(Application.getTunnelManager().create(name, config).toCompletableFuture()); + } + } + } else { + futureTunnels.add(Application.getTunnelManager().create(name, + Config.parse(contentResolver.openInputStream(uri))).toCompletableFuture()); + } + + if (futureTunnels.isEmpty()) { + if (throwables.size() == 1) + throw throwables.get(0); + else if (throwables.isEmpty()) + throw new IllegalArgumentException(getResources().getString(R.string.no_configs_error)); + } + + return CompletableFuture.allOf(futureTunnels.toArray(new CompletableFuture[futureTunnels.size()])); + }).whenComplete((future, exception) -> { + if (exception != null) { + onTunnelImportFinished(Collections.emptyList(), Collections.singletonList(exception)); + } else { + future.whenComplete((ignored1, ignored2) -> { + final List<ObservableTunnel> tunnels = new ArrayList<>(futureTunnels.size()); + for (final CompletableFuture<ObservableTunnel> futureTunnel : futureTunnels) { + ObservableTunnel tunnel = null; + try { + tunnel = futureTunnel.getNow(null); + } catch (final Exception e) { + throwables.add(e); + } + if (tunnel != null) + tunnels.add(tunnel); + } + onTunnelImportFinished(tunnels, throwables); + }); + } + }); + } + + @Override + public void onActivityCreated(@Nullable final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + final Collection<Integer> checkedItems = savedInstanceState.getIntegerArrayList("CHECKED_ITEMS"); + if (checkedItems != null) { + for (final Integer i : checkedItems) + actionModeListener.setItemChecked(i, true); + } + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + switch (requestCode) { + case REQUEST_IMPORT: + if (resultCode == Activity.RESULT_OK && data != null) + importTunnel(data.getData()); + return; + case IntentIntegrator.REQUEST_CODE: + final IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + if (result != null && result.getContents() != null) { + importTunnel(result.getContents()); + } + return; + default: + super.onActivityResult(requestCode, resultCode, data); + } + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + super.onCreateView(inflater, container, savedInstanceState); + binding = TunnelListFragmentBinding.inflate(inflater, container, false); + binding.createFab.setOnClickListener(v -> { + final AddTunnelsSheet bottomSheet = new AddTunnelsSheet(); + bottomSheet.setTargetFragment(this, REQUEST_TARGET_FRAGMENT); + bottomSheet.show(getParentFragmentManager(), "BOTTOM_SHEET"); + }); + binding.executePendingBindings(); + EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot()); + EdgeToEdge.setUpFAB(binding.createFab); + EdgeToEdge.setUpScrollingContent(binding.tunnelList, binding.createFab); + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } + + @Override + public void onPause() { + super.onPause(); + } + + public void onRequestCreateConfig(@SuppressWarnings("unused") final View view) { + startActivity(new Intent(getActivity(), TunnelCreatorActivity.class)); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putIntegerArrayList("CHECKED_ITEMS", actionModeListener.getCheckedItems()); + } + + @Override + public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) { + if (binding == null) + return; + Application.getTunnelManager().getTunnels().thenAccept(tunnels -> { + if (newTunnel != null) + viewForTunnel(newTunnel, tunnels).setSingleSelected(true); + if (oldTunnel != null) + viewForTunnel(oldTunnel, tunnels).setSingleSelected(false); + }); + } + + private void showSnackbar(final CharSequence message) { + if (binding != null) { + final Snackbar snackbar = Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG); + snackbar.setAnchorView(binding.createFab); + snackbar.show(); + } + } + + private void onTunnelDeletionFinished(final Integer count, @Nullable final Throwable throwable) { + final String message; + if (throwable == null) { + message = getResources().getQuantityString(R.plurals.delete_success, count, count); + } else { + final String error = ErrorMessages.get(throwable); + message = getResources().getQuantityString(R.plurals.delete_error, count, count, error); + Log.e(TAG, message, throwable); + } + showSnackbar(message); + } + + private void onTunnelImportFinished(final List<ObservableTunnel> tunnels, final Collection<Throwable> throwables) { + String message = null; + + for (final Throwable throwable : throwables) { + final String error = ErrorMessages.get(throwable); + message = getString(R.string.import_error, error); + Log.e(TAG, message, throwable); + } + + if (tunnels.size() == 1 && throwables.isEmpty()) + message = getString(R.string.import_success, tunnels.get(0).getName()); + else if (tunnels.isEmpty() && throwables.size() == 1) + /* Use the exception message from above. */ ; + else if (throwables.isEmpty()) + message = getResources().getQuantityString(R.plurals.import_total_success, + tunnels.size(), tunnels.size()); + else if (!throwables.isEmpty()) + message = getResources().getQuantityString(R.plurals.import_partial_success, + tunnels.size() + throwables.size(), + tunnels.size(), tunnels.size() + throwables.size()); + + showSnackbar(message); + } + + @Override + public void onViewStateRestored(@Nullable final Bundle savedInstanceState) { + super.onViewStateRestored(savedInstanceState); + + if (binding == null) { + return; + } + + binding.setFragment(this); + Application.getTunnelManager().getTunnels().thenAccept(binding::setTunnels); + binding.setRowConfigurationHandler((ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TunnelListItemBinding, ObservableTunnel>) (binding, tunnel, position) -> { + binding.setFragment(this); + binding.getRoot().setOnClickListener(clicked -> { + if (actionMode == null) { + setSelectedTunnel(tunnel); + } else { + actionModeListener.toggleItemChecked(position); + } + }); + binding.getRoot().setOnLongClickListener(clicked -> { + actionModeListener.toggleItemChecked(position); + return true; + }); + + if (actionMode != null) + ((MultiselectableRelativeLayout) binding.getRoot()).setMultiSelected(actionModeListener.checkedItems.contains(position)); + else + ((MultiselectableRelativeLayout) binding.getRoot()).setSingleSelected(getSelectedTunnel() == tunnel); + }); + } + + private MultiselectableRelativeLayout viewForTunnel(final ObservableTunnel tunnel, final List tunnels) { + return (MultiselectableRelativeLayout) binding.tunnelList.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel)).itemView; + } + + private final class ActionModeListener implements ActionMode.Callback { + private final Collection<Integer> checkedItems = new HashSet<>(); + + @Nullable private Resources resources; + + public ArrayList<Integer> getCheckedItems() { + return new ArrayList<>(checkedItems); + } + + @Override + public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_action_delete: + final Iterable<Integer> copyCheckedItems = new HashSet<>(checkedItems); + Application.getTunnelManager().getTunnels().thenAccept(tunnels -> { + final Collection<ObservableTunnel> tunnelsToDelete = new ArrayList<>(); + for (final Integer position : copyCheckedItems) + tunnelsToDelete.add(tunnels.get(position)); + + final CompletableFuture[] futures = StreamSupport.stream(tunnelsToDelete) + .map(ObservableTunnel::delete) + .toArray(CompletableFuture[]::new); + CompletableFuture.allOf(futures) + .thenApply(x -> futures.length) + .whenComplete(TunnelListFragment.this::onTunnelDeletionFinished); + + }); + checkedItems.clear(); + mode.finish(); + return true; + case R.id.menu_action_select_all: + Application.getTunnelManager().getTunnels().thenAccept(tunnels -> { + for (int i = 0; i < tunnels.size(); ++i) { + setItemChecked(i, true); + } + }); + return true; + default: + return false; + } + } + + @Override + public boolean onCreateActionMode(final ActionMode mode, final Menu menu) { + actionMode = mode; + if (getActivity() != null) { + resources = getActivity().getResources(); + } + mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu); + binding.tunnelList.getAdapter().notifyDataSetChanged(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode mode) { + actionMode = null; + resources = null; + checkedItems.clear(); + binding.tunnelList.getAdapter().notifyDataSetChanged(); + } + + @Override + public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) { + updateTitle(mode); + return false; + } + + void setItemChecked(final int position, final boolean checked) { + if (checked) { + checkedItems.add(position); + } else { + checkedItems.remove(position); + } + + final RecyclerView.Adapter adapter = binding == null ? null : binding.tunnelList.getAdapter(); + + if (actionMode == null && !checkedItems.isEmpty() && getActivity() != null) { + ((AppCompatActivity) getActivity()).startSupportActionMode(this); + } else if (actionMode != null && checkedItems.isEmpty()) { + actionMode.finish(); + } + + if (adapter != null) + adapter.notifyItemChanged(position); + + updateTitle(actionMode); + } + + void toggleItemChecked(final int position) { + setItemChecked(position, !checkedItems.contains(position)); + } + + private void updateTitle(@Nullable final ActionMode mode) { + if (mode == null) { + return; + } + + final int count = checkedItems.size(); + if (count == 0) { + mode.setTitle(""); + } else { + mode.setTitle(resources.getQuantityString(R.plurals.delete_title, count, count)); + } + } + } + +} |