aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/main/java/com/wireguard/android/fragment
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/fragment')
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt106
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java140
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java126
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java118
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java162
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java264
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java449
7 files changed, 1365 insertions, 0 deletions
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
new file mode 100644
index 00000000..3df141be
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.wireguard.android.fragment
+
+import android.content.Intent
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.fragment.app.Fragment
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.zxing.integration.android.IntentIntegrator
+import com.wireguard.android.R
+import com.wireguard.android.activity.TunnelCreatorActivity
+import com.wireguard.android.util.resolveAttribute
+
+class AddTunnelsSheet : BottomSheetDialogFragment() {
+
+ private lateinit var behavior: BottomSheetBehavior<FrameLayout>
+ private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
+ }
+ }
+ }
+
+ override fun getTheme(): Int {
+ return R.style.BottomSheetDialogTheme
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ return inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.peekHeight = 0
+ behavior.addBottomSheetCallback(bottomSheetCallback)
+ dialog.findViewById<View>(R.id.create_empty)?.setOnClickListener {
+ dismiss()
+ onRequestCreateConfig()
+ }
+ dialog.findViewById<View>(R.id.create_from_file)?.setOnClickListener {
+ dismiss()
+ onRequestImportConfig()
+ }
+ dialog.findViewById<View>(R.id.create_from_qrcode)?.setOnClickListener {
+ dismiss()
+ onRequestScanQRCode()
+ }
+ }
+ })
+ val gradientDrawable = GradientDrawable().apply {
+ setColor(requireContext().resolveAttribute(R.attr.colorBackground))
+ }
+ view.background = gradientDrawable
+ }
+
+ override fun dismiss() {
+ super.dismiss()
+ behavior.removeBottomSheetCallback(bottomSheetCallback)
+ }
+
+ private fun requireTargetFragment(): Fragment {
+ return requireNotNull(targetFragment) { "A target fragment should always be set" }
+ }
+
+ private fun onRequestCreateConfig() {
+ startActivity(Intent(activity, TunnelCreatorActivity::class.java))
+ }
+
+ private fun onRequestImportConfig() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ }
+ requireTargetFragment().startActivityForResult(intent, TunnelListFragment.REQUEST_IMPORT)
+ }
+
+ private fun onRequestScanQRCode() {
+ val integrator = IntentIntegrator.forSupportFragment(requireTargetFragment()).apply {
+ setOrientationLocked(false)
+ setBeepEnabled(false)
+ setPrompt(getString(R.string.qr_code_hint))
+ }
+ integrator.initiateScan(listOf(IntentIntegrator.QR_CODE))
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java
new file mode 100644
index 00000000..43178665
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.appcompat.app.AlertDialog;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.AppListDialogFragmentBinding;
+import com.wireguard.android.model.ApplicationData;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.util.ObservableKeyedArrayList;
+import com.wireguard.android.util.ObservableKeyedList;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import java9.util.Comparators;
+import java9.util.stream.Collectors;
+import java9.util.stream.StreamSupport;
+
+public class AppListDialogFragment extends DialogFragment {
+
+ private static final String KEY_EXCLUDED_APPS = "excludedApps";
+ private final ObservableKeyedList<String, ApplicationData> appData = new ObservableKeyedArrayList<>();
+ private List<String> currentlyExcludedApps = Collections.emptyList();
+
+ public static <T extends Fragment & AppExclusionListener>
+ AppListDialogFragment newInstance(final ArrayList<String> excludedApps, final T target) {
+ final Bundle extras = new Bundle();
+ extras.putStringArrayList(KEY_EXCLUDED_APPS, excludedApps);
+ final AppListDialogFragment fragment = new AppListDialogFragment();
+ fragment.setTargetFragment(target, 0);
+ fragment.setArguments(extras);
+ return fragment;
+ }
+
+ private void loadData() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return;
+ }
+
+ final PackageManager pm = activity.getPackageManager();
+ Application.getAsyncWorker().supplyAsync(() -> {
+ final Intent launcherIntent = new Intent(Intent.ACTION_MAIN, null);
+ launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+ final List<ResolveInfo> resolveInfos = pm.queryIntentActivities(launcherIntent, 0);
+
+ final List<ApplicationData> applicationData = new ArrayList<>();
+ for (ResolveInfo resolveInfo : resolveInfos) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ applicationData.add(new ApplicationData(resolveInfo.loadIcon(pm), resolveInfo.loadLabel(pm).toString(), packageName, currentlyExcludedApps.contains(packageName)));
+ }
+
+ Collections.sort(applicationData, Comparators.comparing(ApplicationData::getName, String.CASE_INSENSITIVE_ORDER));
+ return applicationData;
+ }).whenComplete(((data, throwable) -> {
+ if (data != null) {
+ appData.clear();
+ appData.addAll(data);
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ final String message = activity.getString(R.string.error_fetching_apps, error);
+ Toast.makeText(activity, message, Toast.LENGTH_LONG).show();
+ dismissAllowingStateLoss();
+ }
+ }));
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final List<String> excludedApps = requireArguments().getStringArrayList(KEY_EXCLUDED_APPS);
+ currentlyExcludedApps = (excludedApps != null) ? excludedApps : Collections.emptyList();
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
+ final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(requireActivity());
+ alertDialogBuilder.setTitle(R.string.excluded_applications);
+
+ final AppListDialogFragmentBinding binding = AppListDialogFragmentBinding.inflate(requireActivity().getLayoutInflater(), null, false);
+ binding.executePendingBindings();
+ alertDialogBuilder.setView(binding.getRoot());
+
+ alertDialogBuilder.setPositiveButton(R.string.set_exclusions, (dialog, which) -> setExclusionsAndDismiss());
+ alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss());
+ alertDialogBuilder.setNeutralButton(R.string.toggle_all, (dialog, which) -> {
+ });
+
+ binding.setFragment(this);
+ binding.setAppData(appData);
+
+ loadData();
+
+ final AlertDialog dialog = alertDialogBuilder.create();
+ dialog.setOnShowListener(d -> dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener(view -> {
+ final List<ApplicationData> selectedItems = StreamSupport.stream(appData)
+ .filter(ApplicationData::isExcludedFromTunnel)
+ .collect(Collectors.toList());
+ final boolean excludeAll = selectedItems.isEmpty();
+ for (final ApplicationData app : appData)
+ app.setExcludedFromTunnel(excludeAll);
+ }));
+ return dialog;
+ }
+
+ private void setExclusionsAndDismiss() {
+ final List<String> excludedApps = new ArrayList<>();
+ for (final ApplicationData data : appData) {
+ if (data.isExcludedFromTunnel()) {
+ excludedApps.add(data.getPackageName());
+ }
+ }
+
+ ((AppExclusionListener) getTargetFragment()).onExcludedAppsSelected(excludedApps);
+ dismiss();
+ }
+
+ public interface AppExclusionListener {
+ void onExcludedAppsSelected(List<String> excludedApps);
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java
new file mode 100644
index 00000000..23bf44e7
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.databinding.DataBindingUtil;
+import androidx.databinding.ViewDataBinding;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+import androidx.fragment.app.Fragment;
+import android.util.Log;
+import android.view.View;
+import android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.activity.BaseActivity;
+import com.wireguard.android.activity.BaseActivity.OnSelectedTunnelChangedListener;
+import com.wireguard.android.backend.GoBackend;
+import com.wireguard.android.databinding.TunnelDetailFragmentBinding;
+import com.wireguard.android.databinding.TunnelListItemBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.util.ErrorMessages;
+
+/**
+ * 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 static final int REQUEST_CODE_VPN_PERMISSION = 23491;
+ private static final String TAG = "WireGuard/" + BaseFragment.class.getSimpleName();
+ @Nullable private BaseActivity activity;
+ @Nullable private ObservableTunnel pendingTunnel;
+ @Nullable private Boolean pendingTunnelUp;
+
+ @Nullable
+ protected ObservableTunnel getSelectedTunnel() {
+ return activity != null ? activity.getSelectedTunnel() : null;
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
+ if (pendingTunnel != null && pendingTunnelUp != null)
+ setTunnelStateWithPermissionsResult(pendingTunnel, pendingTunnelUp);
+ pendingTunnel = null;
+ pendingTunnelUp = 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(@Nullable final ObservableTunnel tunnel) {
+ if (activity != null)
+ activity.setSelectedTunnel(tunnel);
+ }
+
+ public void setTunnelState(final View view, final boolean checked) {
+ final ViewDataBinding binding = DataBindingUtil.findBinding(view);
+ final ObservableTunnel tunnel;
+ if (binding instanceof TunnelDetailFragmentBinding)
+ tunnel = ((TunnelDetailFragmentBinding) binding).getTunnel();
+ else if (binding instanceof TunnelListItemBinding)
+ tunnel = ((TunnelListItemBinding) binding).getItem();
+ else
+ return;
+ if (tunnel == null)
+ return;
+
+ Application.getBackendAsync().thenAccept(backend -> {
+ if (backend instanceof GoBackend) {
+ final Intent intent = GoBackend.VpnService.prepare(view.getContext());
+ if (intent != null) {
+ pendingTunnel = tunnel;
+ pendingTunnelUp = checked;
+ startActivityForResult(intent, REQUEST_CODE_VPN_PERMISSION);
+ return;
+ }
+ }
+
+ setTunnelStateWithPermissionsResult(tunnel, checked);
+ });
+ }
+
+ private void setTunnelStateWithPermissionsResult(final ObservableTunnel tunnel, final boolean checked) {
+ tunnel.setState(State.of(checked)).whenComplete((state, throwable) -> {
+ if (throwable == null)
+ return;
+ final String error = ErrorMessages.get(throwable);
+ final int messageResId = checked ? R.string.error_up : R.string.error_down;
+ final String message = requireContext().getString(messageResId, error);
+ final View view = getView();
+ if (view != null)
+ Snackbar.make(view, message, Snackbar.LENGTH_LONG).show();
+ else
+ Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
+ Log.e(TAG, message, throwable);
+ });
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java
new file mode 100644
index 00000000..effa0593
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.appcompat.app.AlertDialog;
+import android.view.inputmethod.InputMethodManager;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding;
+import com.wireguard.config.BadConfigException;
+import com.wireguard.config.Config;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+public class ConfigNamingDialogFragment extends DialogFragment {
+ private static final String KEY_CONFIG_TEXT = "config_text";
+
+ @Nullable private ConfigNamingDialogFragmentBinding binding;
+ @Nullable private Config config;
+ @Nullable private InputMethodManager imm;
+
+ public static ConfigNamingDialogFragment newInstance(final String configText) {
+ final Bundle extras = new Bundle();
+ extras.putString(KEY_CONFIG_TEXT, configText);
+ final ConfigNamingDialogFragment fragment = new ConfigNamingDialogFragment();
+ fragment.setArguments(extras);
+ return fragment;
+ }
+
+ private void createTunnelAndDismiss() {
+ if (binding != null) {
+ final String name = binding.tunnelNameText.getText().toString();
+
+ Application.getTunnelManager().create(name, config).whenComplete((tunnel, throwable) -> {
+ if (tunnel != null) {
+ dismiss();
+ } else {
+ binding.tunnelNameTextLayout.setError(throwable.getMessage());
+ }
+ });
+ }
+ }
+
+ @Override
+ public void dismiss() {
+ setKeyboardVisible(false);
+ super.dismiss();
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle arguments = getArguments();
+ final String configText = arguments.getString(KEY_CONFIG_TEXT);
+ final byte[] configBytes = configText.getBytes(StandardCharsets.UTF_8);
+ try {
+ config = Config.parse(new ByteArrayInputStream(configBytes));
+ } catch (final BadConfigException | IOException e) {
+ throw new IllegalArgumentException("Invalid config passed to " + getClass().getSimpleName(), e);
+ }
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Activity activity = requireActivity();
+
+ imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+
+ final AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(activity);
+ alertDialogBuilder.setTitle(R.string.import_from_qr_code);
+
+ binding = ConfigNamingDialogFragmentBinding.inflate(activity.getLayoutInflater(), null, false);
+ binding.executePendingBindings();
+ alertDialogBuilder.setView(binding.getRoot());
+
+ alertDialogBuilder.setPositiveButton(R.string.create_tunnel, null);
+ alertDialogBuilder.setNegativeButton(R.string.cancel, (dialog, which) -> dismiss());
+
+ return alertDialogBuilder.create();
+ }
+
+ @Override public void onResume() {
+ super.onResume();
+
+ final AlertDialog dialog = (AlertDialog) getDialog();
+ if (dialog != null) {
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> createTunnelAndDismiss());
+
+ setKeyboardVisible(true);
+ }
+ }
+
+ private void setKeyboardVisible(final boolean visible) {
+ Objects.requireNonNull(imm);
+
+ if (visible) {
+ imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
+ } else if (binding != null) {
+ imm.hideSoftInputFromWindow(binding.tunnelNameText.getWindowToken(), 0);
+ }
+ }
+
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
new file mode 100644
index 00000000..8d90fa7e
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.databinding.DataBindingUtil;
+
+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.databinding.TunnelDetailPeerBinding;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.backend.Tunnel.State;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.crypto.Key;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * Fragment that shows details about a specific tunnel.
+ */
+
+public class TunnelDetailFragment extends BaseFragment {
+ @Nullable private TunnelDetailFragmentBinding binding;
+ @Nullable private Timer timer;
+ @Nullable private State lastState = State.TOGGLE;
+
+ @Override
+ public void onCreate(@Nullable 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 void onStop() {
+ super.onStop();
+ if (timer != null) {
+ timer.cancel();
+ timer = null;
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ timer = new Timer();
+ timer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ updateStats();
+ }
+ }, 0, 1000);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelDetailFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpScrollingContent((ViewGroup) binding.getRoot(), null);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel, @Nullable final ObservableTunnel newTunnel) {
+ if (binding == null)
+ return;
+ binding.setTunnel(newTunnel);
+ if (newTunnel == null)
+ binding.setConfig(null);
+ else
+ newTunnel.getConfigAsync().thenAccept(binding::setConfig);
+ lastState = State.TOGGLE;
+ updateStats();
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+ onSelectedTunnelChanged(null, getSelectedTunnel());
+ super.onViewStateRestored(savedInstanceState);
+ }
+
+ private String formatBytes(final long bytes) {
+ if (bytes < 1024)
+ return requireContext().getString(R.string.transfer_bytes, bytes);
+ else if (bytes < 1024*1024)
+ return requireContext().getString(R.string.transfer_kibibytes, bytes/1024.0);
+ else if (bytes < 1024*1024*1024)
+ return requireContext().getString(R.string.transfer_mibibytes, bytes/(1024.0*1024.0));
+ else if (bytes < 1024*1024*1024*1024)
+ return requireContext().getString(R.string.transfer_gibibytes, bytes/(1024.0*1024.0*1024.0));
+ return requireContext().getString(R.string.transfer_tibibytes, bytes/(1024.0*1024.0*1024.0)/1024.0);
+ }
+
+ private void updateStats() {
+ if (binding == null || !isResumed())
+ return;
+ final ObservableTunnel tunnel = binding.getTunnel();
+ if (tunnel == null)
+ return;
+ final State state = tunnel.getState();
+ if (state != State.UP && lastState == state)
+ return;
+ lastState = state;
+ tunnel.getStatisticsAsync().whenComplete((statistics, throwable) -> {
+ if (throwable != null) {
+ for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) {
+ final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i));
+ if (peer == null)
+ continue;
+ peer.transferLabel.setVisibility(View.GONE);
+ peer.transferText.setVisibility(View.GONE);
+ }
+ return;
+ }
+ for (int i = 0; i < binding.peersLayout.getChildCount(); ++i) {
+ final TunnelDetailPeerBinding peer = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i));
+ if (peer == null)
+ continue;
+ final Key publicKey = peer.getItem().getPublicKey();
+ final long rx = statistics.peerRx(publicKey);
+ final long tx = statistics.peerTx(publicKey);
+ if (rx == 0 && tx == 0) {
+ peer.transferLabel.setVisibility(View.GONE);
+ peer.transferText.setVisibility(View.GONE);
+ continue;
+ }
+ peer.transferText.setText(requireContext().getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx)));
+ peer.transferLabel.setVisibility(View.VISIBLE);
+ peer.transferText.setVisibility(View.VISIBLE);
+ }
+ });
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java
new file mode 100644
index 00000000..92aeb52a
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import androidx.databinding.ObservableList;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+
+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 android.widget.Toast;
+
+import com.wireguard.android.Application;
+import com.wireguard.android.R;
+import com.wireguard.android.databinding.TunnelEditorFragmentBinding;
+import com.wireguard.android.fragment.AppListDialogFragment.AppExclusionListener;
+import com.wireguard.android.model.ObservableTunnel;
+import com.wireguard.android.model.TunnelManager;
+import com.wireguard.android.ui.EdgeToEdge;
+import com.wireguard.android.util.ErrorMessages;
+import com.wireguard.android.viewmodel.ConfigProxy;
+import com.wireguard.config.Config;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Fragment for editing a WireGuard configuration.
+ */
+
+public class TunnelEditorFragment extends BaseFragment implements AppExclusionListener {
+ private static final String KEY_LOCAL_CONFIG = "local_config";
+ private static final String KEY_ORIGINAL_NAME = "original_name";
+ private static final String TAG = "WireGuard/" + TunnelEditorFragment.class.getSimpleName();
+
+ @Nullable private TunnelEditorFragmentBinding binding;
+ @Nullable private ObservableTunnel tunnel;
+
+ private void onConfigLoaded(final Config config) {
+ if (binding != null) {
+ binding.setConfig(new ConfigProxy(config));
+ }
+ }
+
+ private void onConfigSaved(final ObservableTunnel savedTunnel,
+ @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getString(R.string.config_save_success, savedTunnel.getName());
+ Log.d(TAG, message);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ onFinished();
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.config_save_error, savedTunnel.getName(), error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onCreate(@Nullable final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ 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, @Nullable final ViewGroup container,
+ @Nullable final Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ binding = TunnelEditorFragmentBinding.inflate(inflater, container, false);
+ binding.executePendingBindings();
+ EdgeToEdge.setUpRoot((ViewGroup) binding.getRoot());
+ EdgeToEdge.setUpScrollingContent(binding.mainContainer, null);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onDestroyView() {
+ binding = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onExcludedAppsSelected(final List<String> excludedApps) {
+ Objects.requireNonNull(binding, "Tried to set excluded apps while no view was loaded");
+ final ObservableList<String> excludedApplications =
+ binding.getConfig().getInterface().getExcludedApplications();
+ excludedApplications.clear();
+ excludedApplications.addAll(excludedApps);
+ }
+
+ private void onFinished() {
+ // Hide the keyboard; it rarely goes away on its own.
+ final Activity activity = getActivity();
+ if (activity == null) return;
+ 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(() -> {
+ // TODO(smaeul): Remove this hack when fixing the Config ViewModel
+ // The selected tunnel has to actually change, but we have to remember this one.
+ final ObservableTunnel savedTunnel = tunnel;
+ if (savedTunnel == getSelectedTunnel())
+ setSelectedTunnel(null);
+ setSelectedTunnel(savedTunnel);
+ });
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.menu_action_save:
+ if (binding == null)
+ return false;
+ final Config newConfig;
+ try {
+ newConfig = binding.getConfig().resolve();
+ } catch (final Exception e) {
+ final String error = ErrorMessages.get(e);
+ final String tunnelName = tunnel == null ? binding.getName() : tunnel.getName();
+ final String message = getString(R.string.config_save_error, tunnelName, error);
+ Log.e(TAG, message, e);
+ Snackbar.make(binding.mainContainer, error, Snackbar.LENGTH_LONG).show();
+ return false;
+ }
+ if (tunnel == null) {
+ Log.d(TAG, "Attempting to create new tunnel " + binding.getName());
+ final TunnelManager manager = Application.getTunnelManager();
+ manager.create(binding.getName(), newConfig)
+ .whenComplete(this::onTunnelCreated);
+ } else if (!tunnel.getName().equals(binding.getName())) {
+ Log.d(TAG, "Attempting to rename tunnel to " + binding.getName());
+ tunnel.setName(binding.getName())
+ .whenComplete((a, b) -> onTunnelRenamed(tunnel, newConfig, b));
+ } else {
+ Log.d(TAG, "Attempting to save config of " + tunnel.getName());
+ tunnel.setConfig(newConfig)
+ .whenComplete((a, b) -> onConfigSaved(tunnel, b));
+ }
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ public void onRequestSetExcludedApplications(@SuppressWarnings("unused") final View view) {
+ if (binding != null) {
+ final ArrayList<String> excludedApps = new ArrayList<>(binding.getConfig().getInterface().getExcludedApplications());
+ final AppListDialogFragment fragment = AppListDialogFragment.newInstance(excludedApps, this);
+ fragment.show(getParentFragmentManager(), null);
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ if (binding != null)
+ outState.putParcelable(KEY_LOCAL_CONFIG, binding.getConfig());
+ outState.putString(KEY_ORIGINAL_NAME, tunnel == null ? null : tunnel.getName());
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onSelectedTunnelChanged(@Nullable final ObservableTunnel oldTunnel,
+ @Nullable final ObservableTunnel newTunnel) {
+ tunnel = newTunnel;
+ if (binding == null)
+ return;
+ binding.setConfig(new ConfigProxy());
+ if (tunnel != null) {
+ binding.setName(tunnel.getName());
+ tunnel.getConfigAsync().thenAccept(this::onConfigLoaded);
+ } else {
+ binding.setName("");
+ }
+ }
+
+ private void onTunnelCreated(final ObservableTunnel newTunnel, @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ tunnel = newTunnel;
+ message = getString(R.string.tunnel_create_success, tunnel.getName());
+ Log.d(TAG, message);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ onFinished();
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.tunnel_create_error, error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ private void onTunnelRenamed(final ObservableTunnel renamedTunnel, final Config newConfig,
+ @Nullable final Throwable throwable) {
+ final String message;
+ if (throwable == null) {
+ message = getString(R.string.tunnel_rename_success, renamedTunnel.getName());
+ Log.d(TAG, message);
+ // Now save the rest of configuration changes.
+ Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel.getName());
+ renamedTunnel.setConfig(newConfig).whenComplete((a, b) -> onConfigSaved(renamedTunnel, b));
+ } else {
+ final String error = ErrorMessages.get(throwable);
+ message = getString(R.string.tunnel_rename_error, error);
+ Log.e(TAG, message, throwable);
+ if (binding != null) {
+ Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ @Override
+ public void onViewStateRestored(@Nullable final Bundle savedInstanceState) {
+ if (binding == null) {
+ return;
+ }
+
+ binding.setFragment(this);
+
+ if (savedInstanceState == null) {
+ onSelectedTunnelChanged(null, getSelectedTunnel());
+ } else {
+ tunnel = getSelectedTunnel();
+ final ConfigProxy config = savedInstanceState.getParcelable(KEY_LOCAL_CONFIG);
+ final String originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME);
+ if (tunnel != null && !tunnel.getName().equals(originalName))
+ onSelectedTunnelChanged(null, tunnel);
+ else
+ binding.setConfig(config);
+ }
+
+ super.onViewStateRestored(savedInstanceState);
+ }
+
+}
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));
+ }
+ }
+ }
+
+}