diff options
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/fragment')
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)); + } + } + } + +} |