diff options
Diffstat (limited to 'ui')
12 files changed, 1119 insertions, 1271 deletions
diff --git a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java deleted file mode 100644 index ceb1725c..00000000 --- a/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * 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 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 com.wireguard.util.NonNullForAll; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import java9.util.Comparators; -import java9.util.stream.Collectors; -import java9.util.stream.StreamSupport; - -@NonNullForAll -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/AppListDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt new file mode 100644 index 00000000..01dadb08 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt @@ -0,0 +1,117 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +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 java9.util.Comparators +import java9.util.function.Function +import java.util.Collections + +class AppListDialogFragment : DialogFragment() { + private val appData: ObservableKeyedList<String, ApplicationData> = ObservableKeyedArrayList() + private var currentlyExcludedApps = emptyList<String>() + + private fun loadData() { + val activity = activity ?: return + val pm = activity.packageManager + Application.getAsyncWorker().supplyAsync<List<ApplicationData>> { + val launcherIntent = Intent(Intent.ACTION_MAIN, null) + launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER) + val resolveInfos = pm.queryIntentActivities(launcherIntent, 0) + val applicationData: MutableList<ApplicationData> = ArrayList() + resolveInfos.forEach { + val packageName = it.activityInfo.packageName + applicationData.add(ApplicationData(it.loadIcon(pm), it.loadLabel(pm).toString(), packageName, currentlyExcludedApps.contains(packageName))) + } + + Collections.sort(applicationData, Comparators.comparing(Function { obj: ApplicationData -> obj.name }, java.lang.String.CASE_INSENSITIVE_ORDER)) + applicationData + }.whenComplete { data, throwable -> + if (data != null) { + appData.clear() + appData.addAll(data) + } else { + val error = ErrorMessages.get(throwable) + val message = activity.getString(R.string.error_fetching_apps, error) + Toast.makeText(activity, message, Toast.LENGTH_LONG).show() + dismissAllowingStateLoss() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val excludedApps = requireArguments().getStringArrayList(KEY_EXCLUDED_APPS) + currentlyExcludedApps = (excludedApps ?: emptyList()) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val alertDialogBuilder = AlertDialog.Builder(requireActivity()) + alertDialogBuilder.setTitle(R.string.excluded_applications) + val binding = AppListDialogFragmentBinding.inflate(requireActivity().layoutInflater, null, false) + binding.executePendingBindings() + alertDialogBuilder.setView(binding.root) + alertDialogBuilder.setPositiveButton(R.string.set_exclusions) { _, _ -> setExclusionsAndDismiss() } + alertDialogBuilder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + alertDialogBuilder.setNeutralButton(R.string.toggle_all) { _, _ -> } + binding.fragment = this + binding.appData = appData + loadData() + val dialog = alertDialogBuilder.create() + dialog.setOnShowListener { + dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener { + val selectedItems = appData + .filter { obj: ApplicationData -> obj.isExcludedFromTunnel } + + val excludeAll = selectedItems.isEmpty() + appData.forEach { + it.isExcludedFromTunnel = excludeAll + } + } + } + return dialog + } + + private fun setExclusionsAndDismiss() { + val excludedApps: MutableList<String> = ArrayList() + for (data in appData) { + if (data.isExcludedFromTunnel) { + excludedApps.add(data.packageName) + } + } + (targetFragment as AppExclusionListener?)!!.onExcludedAppsSelected(excludedApps) + dismiss() + } + + interface AppExclusionListener { + fun onExcludedAppsSelected(excludedApps: List<String>) + } + + companion object { + private const val KEY_EXCLUDED_APPS = "excludedApps" + fun <T> newInstance(excludedApps: ArrayList<String?>?, target: T): AppListDialogFragment where T : Fragment?, T : AppExclusionListener? { + val extras = Bundle() + extras.putStringArrayList(KEY_EXCLUDED_APPS, excludedApps) + val fragment = AppListDialogFragment() + fragment.setTargetFragment(target, 0) + fragment.arguments = extras + return fragment + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java deleted file mode 100644 index 9e7b635a..00000000 --- a/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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 android.util.Log; -import android.view.View; -import android.widget.Toast; - -import com.google.android.material.snackbar.Snackbar; -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.backend.Tunnel.State; -import com.wireguard.android.databinding.TunnelDetailFragmentBinding; -import com.wireguard.android.databinding.TunnelListItemBinding; -import com.wireguard.android.model.ObservableTunnel; -import com.wireguard.android.util.ErrorMessages; -import com.wireguard.util.NonNullForAll; - -import androidx.annotation.Nullable; -import androidx.databinding.DataBindingUtil; -import androidx.databinding.ViewDataBinding; -import androidx.fragment.app.Fragment; - -/** - * Base class for fragments that need to know the currently-selected tunnel. Only does anything when - * attached to a {@code BaseActivity}. - */ - -@NonNullForAll -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).setAnchorView(view.findViewById(R.id.create_fab)).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/BaseFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt new file mode 100644 index 00000000..12b99f25 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt @@ -0,0 +1,108 @@ +/* + * 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 android.util.Log +import android.view.View +import android.widget.Toast +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar +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.Backend +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.TunnelDetailFragmentBinding +import com.wireguard.android.databinding.TunnelListItemBinding +import com.wireguard.android.model.ObservableTunnel +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 `BaseActivity`. + */ +abstract class BaseFragment : Fragment(), OnSelectedTunnelChangedListener { + private var baseActivity: BaseActivity? = null + private var pendingTunnel: ObservableTunnel? = null + private var pendingTunnelUp: Boolean? = null + protected var selectedTunnel: ObservableTunnel? + get() = baseActivity?.selectedTunnel + protected set(tunnel) { + baseActivity?.selectedTunnel = tunnel + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE_VPN_PERMISSION) { + if (pendingTunnel != null && pendingTunnelUp != null) setTunnelStateWithPermissionsResult(pendingTunnel!!, pendingTunnelUp!!) + pendingTunnel = null + pendingTunnelUp = null + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is BaseActivity) { + baseActivity = context + baseActivity?.addOnSelectedTunnelChangedListener(this) + } else { + baseActivity = null + } + } + + override fun onDetach() { + baseActivity?.removeOnSelectedTunnelChangedListener(this) + baseActivity = null + super.onDetach() + } + + fun setTunnelState(view: View, checked: Boolean) { + val tunnel = when (val binding = DataBindingUtil.findBinding<ViewDataBinding>(view)) { + is TunnelDetailFragmentBinding -> binding.tunnel + is TunnelListItemBinding -> binding.item + else -> return + } + Application.getBackendAsync().thenAccept { backend: Backend? -> + if (backend is GoBackend) { + val intent = GoBackend.VpnService.prepare(view.context) + if (intent != null) { + pendingTunnel = tunnel + pendingTunnelUp = checked + startActivityForResult(intent, REQUEST_CODE_VPN_PERMISSION) + return@thenAccept + } + } + setTunnelStateWithPermissionsResult(tunnel!!, checked) + } + } + + private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel, checked: Boolean) { + tunnel.setState(Tunnel.State.of(checked)).whenComplete { _, throwable -> + if (throwable == null) return@whenComplete + val error = ErrorMessages.get(throwable) + val messageResId = if (checked) R.string.error_up else R.string.error_down + val message = requireContext().getString(messageResId, error) + val view = view + if (view != null) + Snackbar.make(view, message, Snackbar.LENGTH_LONG) + .setAnchorView(view.findViewById<View>(R.id.create_fab)) + .show() + else + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, throwable) + } + } + + companion object { + private const val REQUEST_CODE_VPN_PERMISSION = 23491 + private val TAG = "WireGuard/" + BaseFragment::class.java.simpleName + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java deleted file mode 100644 index aa152172..00000000 --- a/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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 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 com.wireguard.util.NonNullForAll; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; - -@NonNullForAll -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/ConfigNamingDialogFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt new file mode 100644 index 00000000..6a60ead6 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt @@ -0,0 +1,105 @@ +/* + * 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 android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.databinding.ConfigNamingDialogFragmentBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.config.BadConfigException +import com.wireguard.config.Config +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.charset.StandardCharsets + +class ConfigNamingDialogFragment : DialogFragment() { + private var binding: ConfigNamingDialogFragmentBinding? = null + private var config: Config? = null + private var imm: InputMethodManager? = null + + private fun createTunnelAndDismiss() { + if (binding != null) { + val name = binding!!.tunnelNameText.text.toString() + Application.getTunnelManager().create(name, config).whenComplete { tunnel: ObservableTunnel?, throwable: Throwable -> + if (tunnel != null) { + dismiss() + } else { + binding!!.tunnelNameTextLayout.error = throwable.message + } + } + } + } + + override fun dismiss() { + setKeyboardVisible(false) + super.dismiss() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configText = requireArguments().getString(KEY_CONFIG_TEXT) + val configBytes = configText!!.toByteArray(StandardCharsets.UTF_8) + config = try { + Config.parse(ByteArrayInputStream(configBytes)) + } catch (e: BadConfigException) { + throw IllegalArgumentException("Invalid config passed to " + javaClass.simpleName, e) + } catch (e: IOException) { + throw IllegalArgumentException("Invalid config passed to " + javaClass.simpleName, e) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity: Activity = requireActivity() + imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val alertDialogBuilder = AlertDialog.Builder(activity) + alertDialogBuilder.setTitle(R.string.import_from_qr_code) + binding = ConfigNamingDialogFragmentBinding.inflate(activity.layoutInflater, null, false) + binding?.apply { + executePendingBindings() + alertDialogBuilder.setView(root) + } + alertDialogBuilder.setPositiveButton(R.string.create_tunnel, null) + alertDialogBuilder.setNegativeButton(R.string.cancel) { _, _ -> dismiss() } + return alertDialogBuilder.create() + } + + override fun onResume() { + super.onResume() + val dialog = dialog as AlertDialog? + if (dialog != null) { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { createTunnelAndDismiss() } + setKeyboardVisible(true) + } + } + + private fun setKeyboardVisible(visible: Boolean) { + if (visible) { + imm!!.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) + } else if (binding != null) { + imm!!.hideSoftInputFromWindow(binding!!.tunnelNameText.windowToken, 0) + } + } + + companion object { + private const val KEY_CONFIG_TEXT = "config_text" + + @JvmStatic + fun newInstance(configText: String?): ConfigNamingDialogFragment { + val extras = Bundle() + extras.putString(KEY_CONFIG_TEXT, configText) + val fragment = ConfigNamingDialogFragment() + fragment.arguments = extras + return fragment + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java deleted file mode 100644 index eb76a220..00000000 --- a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.java +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.wireguard.android.fragment; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.wireguard.android.R; -import com.wireguard.android.backend.Tunnel.State; -import com.wireguard.android.databinding.TunnelDetailFragmentBinding; -import com.wireguard.android.databinding.TunnelDetailPeerBinding; -import com.wireguard.android.model.ObservableTunnel; -import com.wireguard.android.ui.EdgeToEdge; -import com.wireguard.crypto.Key; -import com.wireguard.util.NonNullForAll; - -import java.util.Timer; -import java.util.TimerTask; - -import androidx.annotation.Nullable; -import androidx.databinding.DataBindingUtil; - -/** - * Fragment that shows details about a specific tunnel. - */ - -@NonNullForAll -public class TunnelDetailFragment extends BaseFragment { - @Nullable private TunnelDetailFragmentBinding binding; - @Nullable private State lastState = State.TOGGLE; - @Nullable private Timer timer; - - @SuppressWarnings("MagicNumber") - 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 * 1024L) - 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); - } - - @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 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 onResume() { - super.onResume(); - timer = new Timer(); - timer.scheduleAtFixedRate(new TimerTask() { - @Override - public void run() { - updateStats(); - } - }, 0, 1000); - } - - @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 onStop() { - super.onStop(); - if (timer != null) { - timer.cancel(); - timer = null; - } - } - - @Override - public void onViewStateRestored(@Nullable final Bundle savedInstanceState) { - if (binding == null) { - return; - } - - binding.setFragment(this); - onSelectedTunnelChanged(null, getSelectedTunnel()); - super.onViewStateRestored(savedInstanceState); - } - - 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/TunnelDetailFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt new file mode 100644 index 00000000..22e5d94e --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt @@ -0,0 +1,138 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import com.wireguard.android.R +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.TunnelDetailFragmentBinding +import com.wireguard.android.databinding.TunnelDetailPeerBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.ui.EdgeToEdge.setUpRoot +import com.wireguard.android.ui.EdgeToEdge.setUpScrollingContent +import com.wireguard.config.Config +import java.util.Timer +import java.util.TimerTask + +/** + * Fragment that shows details about a specific tunnel. + */ +class TunnelDetailFragment : BaseFragment() { + private var binding: TunnelDetailFragmentBinding? = null + private var lastState: Tunnel.State? = Tunnel.State.TOGGLE + private var timer: Timer? = null + + private fun formatBytes(bytes: Long): String { + val context = requireContext() + return when { + bytes < 1024 -> context.getString(R.string.transfer_bytes, bytes) + bytes < 1024 * 1024 -> context.getString(R.string.transfer_kibibytes, bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> context.getString(R.string.transfer_mibibytes, bytes / (1024.0 * 1024.0)) + bytes < 1024 * 1024 * 1024 * 1024L -> context.getString(R.string.transfer_gibibytes, bytes / (1024.0 * 1024.0 * 1024.0)) + else -> context.getString(R.string.transfer_tibibytes, bytes / (1024.0 * 1024.0 * 1024.0) / 1024.0) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.tunnel_detail, menu) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelDetailFragmentBinding.inflate(inflater, container, false) + binding?.apply { + executePendingBindings() + setUpRoot(root as ViewGroup) + setUpScrollingContent(root as ViewGroup, null) + } + return binding!!.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + timer = Timer() + timer!!.scheduleAtFixedRate(object : TimerTask() { + override fun run() { + updateStats() + } + }, 0, 1000) + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { + if (binding == null) return + binding!!.tunnel = newTunnel + if (newTunnel == null) binding!!.config = null else newTunnel.configAsync.thenAccept { config: Config? -> binding!!.config = config } + lastState = Tunnel.State.TOGGLE + updateStats() + } + + override fun onStop() { + super.onStop() + if (timer != null) { + timer!!.cancel() + timer = null + } + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + if (binding == null) { + return + } + binding!!.fragment = this + onSelectedTunnelChanged(null, selectedTunnel) + super.onViewStateRestored(savedInstanceState) + } + + private fun updateStats() { + if (binding == null || !isResumed) return + val tunnel = binding!!.tunnel ?: return + val state = tunnel.state + if (state != Tunnel.State.UP && lastState == state) return + lastState = state + tunnel.statisticsAsync.whenComplete { statistics, throwable -> + if (throwable != null) { + for (i in 0 until binding!!.peersLayout.childCount) { + val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding!!.peersLayout.getChildAt(i)) + ?: continue + peer.transferLabel.visibility = View.GONE + peer.transferText.visibility = View.GONE + } + return@whenComplete + } + for (i in 0 until binding!!.peersLayout.childCount) { + val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding!!.peersLayout.getChildAt(i)) + ?: continue + val publicKey = peer.item!!.publicKey + val rx = statistics.peerRx(publicKey) + val tx = statistics.peerTx(publicKey) + if (rx == 0L && tx == 0L) { + peer.transferLabel.visibility = View.GONE + peer.transferText.visibility = View.GONE + continue + } + peer.transferText.text = requireContext().getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx)) + peer.transferLabel.visibility = View.VISIBLE + peer.transferText.visibility = 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 deleted file mode 100644 index cf3ee34b..00000000 --- a/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.java +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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 android.os.Bundle; -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.google.android.material.snackbar.Snackbar; -import com.wireguard.android.Application; -import com.wireguard.android.R; -import com.wireguard.android.backend.Tunnel; -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 com.wireguard.util.NonNullForAll; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -import androidx.annotation.Nullable; -import androidx.databinding.ObservableList; - -/** - * Fragment for editing a WireGuard configuration. - */ - -@NonNullForAll -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 Tunnel 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 InputMethodManager inputManager = (InputMethodManager) - activity.getSystemService(Context.INPUT_METHOD_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. - requireActivity().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) { - if (item.getItemId() == 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; - } - 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/TunnelEditorFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt new file mode 100644 index 00000000..b69f36bd --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt @@ -0,0 +1,236 @@ +/* + * 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.os.Bundle +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.google.android.material.snackbar.Snackbar +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.TunnelEditorFragmentBinding +import com.wireguard.android.fragment.AppListDialogFragment.AppExclusionListener +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.ui.EdgeToEdge.setUpRoot +import com.wireguard.android.ui.EdgeToEdge.setUpScrollingContent +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.viewmodel.ConfigProxy +import com.wireguard.config.Config + +/** + * Fragment for editing a WireGuard configuration. + */ +class TunnelEditorFragment : BaseFragment(), AppExclusionListener { + private var binding: TunnelEditorFragmentBinding? = null + private var tunnel: ObservableTunnel? = null + private fun onConfigLoaded(config: Config) { + if (binding != null) { + binding!!.config = ConfigProxy(config) + } + } + + private fun onConfigSaved(savedTunnel: Tunnel, throwable: Throwable?) { + val message: String + if (throwable == null) { + message = getString(R.string.config_save_success, savedTunnel.name) + Log.d(TAG, message) + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + onFinished() + } else { + val error = ErrorMessages.get(throwable) + message = getString(R.string.config_save_error, savedTunnel.name, error) + Log.e(TAG, message, throwable) + if (binding != null) { + Snackbar.make(binding!!.mainContainer, message, Snackbar.LENGTH_LONG).show() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.config_editor, menu) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelEditorFragmentBinding.inflate(inflater, container, false) + binding?.apply { + executePendingBindings() + setUpRoot(root as ViewGroup) + setUpScrollingContent(mainContainer, null) + } + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onExcludedAppsSelected(excludedApps: List<String>) { + requireNotNull(binding) { "Tried to set excluded apps while no view was loaded" } + binding!!.config!!.getInterface().excludedApplications.apply { + clear() + addAll(excludedApps) + } + } + + private fun onFinished() { + // Hide the keyboard; it rarely goes away on its own. + val activity = activity ?: return + val focusedView = activity.currentFocus + if (focusedView != null) { + val inputManager = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + inputManager?.hideSoftInputFromWindow(focusedView.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS) + } + // Tell the activity to finish itself or go back to the detail view. + requireActivity().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. + val savedTunnel = tunnel + if (savedTunnel === selectedTunnel) selectedTunnel = null + selectedTunnel = savedTunnel + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.menu_action_save) { + if (binding == null) return false + val newConfig: Config + newConfig = try { + binding!!.config!!.resolve() + } catch (e: Exception) { + val error = ErrorMessages.get(e) + val tunnelName = if (tunnel == null) binding!!.name else tunnel!!.name + val 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 + } + when { + tunnel == null -> { + Log.d(TAG, "Attempting to create new tunnel " + binding!!.name) + val manager = Application.getTunnelManager() + manager.create(binding!!.name, newConfig) + .whenComplete { newTunnel, throwable -> onTunnelCreated(newTunnel, throwable) } + } + tunnel!!.name != binding!!.name -> { + Log.d(TAG, "Attempting to rename tunnel to " + binding!!.name) + tunnel!!.setName(binding!!.name) + .whenComplete { _, t -> onTunnelRenamed(tunnel!!, newConfig, t) } + } + else -> { + Log.d(TAG, "Attempting to save config of " + tunnel!!.name) + tunnel!!.setConfig(newConfig) + .whenComplete { _, t -> onConfigSaved(tunnel!!, t) } + } + } + return true + } + return super.onOptionsItemSelected(item) + } + + @Suppress("UNUSED_PARAMETER") + fun onRequestSetExcludedApplications(view: View?) { + if (binding != null) { + val excludedApps = ArrayList(binding!!.config!!.getInterface().excludedApplications) + val fragment = AppListDialogFragment.newInstance(excludedApps, this) + fragment.show(parentFragmentManager, null) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + if (binding != null) outState.putParcelable(KEY_LOCAL_CONFIG, binding!!.config) + outState.putString(KEY_ORIGINAL_NAME, if (tunnel == null) null else tunnel!!.name) + super.onSaveInstanceState(outState) + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, + newTunnel: ObservableTunnel?) { + tunnel = newTunnel + if (binding == null) return + binding!!.config = ConfigProxy() + if (tunnel != null) { + binding!!.name = tunnel!!.name + tunnel!!.configAsync.thenAccept { config: Config -> onConfigLoaded(config) } + } else { + binding!!.name = "" + } + } + + private fun onTunnelCreated(newTunnel: ObservableTunnel, throwable: Throwable?) { + val message: String + if (throwable == null) { + tunnel = newTunnel + message = getString(R.string.tunnel_create_success, tunnel!!.name) + Log.d(TAG, message) + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + onFinished() + } else { + val 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 fun onTunnelRenamed(renamedTunnel: ObservableTunnel, newConfig: Config, + throwable: Throwable?) { + val message: String + if (throwable == null) { + message = getString(R.string.tunnel_rename_success, renamedTunnel.name) + Log.d(TAG, message) + // Now save the rest of configuration changes. + Log.d(TAG, "Attempting to save config of renamed tunnel " + tunnel!!.name) + renamedTunnel.setConfig(newConfig).whenComplete { _, t -> onConfigSaved(renamedTunnel, t) } + } else { + val 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 fun onViewStateRestored(savedInstanceState: Bundle?) { + if (binding == null) { + return + } + binding!!.fragment = this + if (savedInstanceState == null) { + onSelectedTunnelChanged(null, selectedTunnel) + } else { + tunnel = selectedTunnel + val config: ConfigProxy = savedInstanceState.getParcelable(KEY_LOCAL_CONFIG)!! + val originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME) + if (tunnel != null && tunnel!!.name != originalName) onSelectedTunnelChanged(null, tunnel) else binding!!.config = config + } + super.onViewStateRestored(savedInstanceState) + } + + companion object { + private const val KEY_LOCAL_CONFIG = "local_config" + private const val KEY_ORIGINAL_NAME = "original_name" + private val TAG = "WireGuard/" + TunnelEditorFragment::class.java.simpleName + } +} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java deleted file mode 100644 index 7ed82f1f..00000000 --- a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.java +++ /dev/null @@ -1,450 +0,0 @@ -/* - * 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 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.android.material.snackbar.Snackbar; -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 com.wireguard.util.NonNullForAll; - -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 androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ActionMode; -import androidx.recyclerview.widget.RecyclerView; -import java9.util.concurrent.CompletableFuture; -import java9.util.stream.StreamSupport; - -/** - * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels. - */ - -@NonNullForAll -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 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 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 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)); - } - } - } - -} diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt new file mode 100644 index 00000000..051babb2 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt @@ -0,0 +1,415 @@ +/* + * 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.Intent +import android.content.res.Resources +import android.net.Uri +import android.os.Bundle +import android.provider.OpenableColumns +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 androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import com.google.android.material.snackbar.Snackbar +import com.google.zxing.integration.android.IntentIntegrator +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.activity.TunnelCreatorActivity +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler +import com.wireguard.android.databinding.TunnelListFragmentBinding +import com.wireguard.android.databinding.TunnelListItemBinding +import com.wireguard.android.fragment.ConfigNamingDialogFragment.Companion.newInstance +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.ui.EdgeToEdge.setUpFAB +import com.wireguard.android.ui.EdgeToEdge.setUpRoot +import com.wireguard.android.ui.EdgeToEdge.setUpScrollingContent +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.ObservableSortedKeyedList +import com.wireguard.android.widget.MultiselectableRelativeLayout +import com.wireguard.config.BadConfigException +import com.wireguard.config.Config +import java9.util.concurrent.CompletableFuture +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.HashSet +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * Fragment containing a list of known WireGuard tunnels. It allows creating and deleting tunnels. + */ +class TunnelListFragment : BaseFragment() { + private val actionModeListener = ActionModeListener() + private var actionMode: ActionMode? = null + private var binding: TunnelListFragmentBinding? = null + private fun importTunnel(configText: String) { + try { + // Ensure the config text is parseable before proceeding… + Config.parse(ByteArrayInputStream(configText.toByteArray(StandardCharsets.UTF_8))) + + // Config text is valid, now create the tunnel… + newInstance(configText).show(parentFragmentManager, null) + } catch (e: BadConfigException) { + onTunnelImportFinished(emptyList(), listOf<Throwable>(e)) + } catch (e: IOException) { + onTunnelImportFinished(emptyList(), listOf<Throwable>(e)) + } + } + + private fun importTunnel(uri: Uri?) { + val activity = activity + if (activity == null || uri == null) { + return + } + val contentResolver = activity.contentResolver + + val futureTunnels = ArrayList<CompletableFuture<ObservableTunnel>>() + val throwables = ArrayList<Throwable>() + Application.getAsyncWorker().supplyAsync { + val columns = arrayOf(OpenableColumns.DISPLAY_NAME) + var name = "" + contentResolver.query(uri, columns, null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) { + name = cursor.getString(0) + } + cursor.close() + } + if (name.isEmpty()) { + name = Uri.decode(uri.lastPathSegment) + } + var idx = name.lastIndexOf('/') + if (idx >= 0) { + require(idx < name.length - 1) { "Illegal file name: $name" } + name = name.substring(idx + 1) + } + val isZip = name.toLowerCase(Locale.ROOT).endsWith(".zip") + if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) { + name = name.substring(0, name.length - ".conf".length) + } else { + require(isZip) { "File must be .conf or .zip" } + } + + if (isZip) { + ZipInputStream(contentResolver.openInputStream(uri)).use { zip -> + val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8)) + var entry: ZipEntry + while (zip.nextEntry.also { entry = it } != null) { + name = entry.name + idx = name.lastIndexOf('/') + if (idx >= 0) { + if (idx >= name.length - 1) { + continue + } + name = name.substring(name.lastIndexOf('/') + 1) + } + if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) { + name = name.substring(0, name.length - ".conf".length) + } else { + continue + } + val config: Config? = try { + Config.parse(reader) + } catch (e: Exception) { + throwables.add(e) + null + } + + 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[0] + } else { + require(throwables.isNotEmpty()) { "No configurations found" } + } + } + CompletableFuture.allOf(*futureTunnels.toTypedArray()) + }.whenComplete { future, exception -> + if (exception != null) { + onTunnelImportFinished(emptyList(), listOf(exception)) + } else { + future.whenComplete { _, _ -> + val tunnels = mutableListOf<ObservableTunnel>() + for (futureTunnel in futureTunnels) { + val tunnel: ObservableTunnel? = try { + futureTunnel.getNow(null) + } catch (e: Exception) { + throwables.add(e) + null + } + + if (tunnel != null) { + tunnels.add(tunnel) + } + } + onTunnelImportFinished(tunnels, throwables) + } + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + if (savedInstanceState != null) { + val checkedItems: Collection<Int>? = savedInstanceState.getIntegerArrayList("CHECKED_ITEMS") + if (checkedItems != null) { + for (i in checkedItems) actionModeListener.setItemChecked(i, true) + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + REQUEST_IMPORT -> { + if (resultCode == Activity.RESULT_OK && data != null) importTunnel(data.data) + return + } + IntentIntegrator.REQUEST_CODE -> { + val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (result != null && result.contents != null) { + importTunnel(result.contents) + } + return + } + else -> super.onActivityResult(requestCode, resultCode, data) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelListFragmentBinding.inflate(inflater, container, false) + binding?.apply { + createFab.setOnClickListener { + val bottomSheet = AddTunnelsSheet() + bottomSheet.setTargetFragment(fragment, REQUEST_TARGET_FRAGMENT) + bottomSheet.show(parentFragmentManager, "BOTTOM_SHEET") + } + executePendingBindings() + setUpRoot(root as ViewGroup) + setUpFAB(createFab) + setUpScrollingContent(tunnelList, createFab) + } + return binding!!.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + fun onRequestCreateConfig(view: View?) { + startActivity(Intent(activity, TunnelCreatorActivity::class.java)) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putIntegerArrayList("CHECKED_ITEMS", actionModeListener.getCheckedItems()) + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { + if (binding == null) return + Application.getTunnelManager().tunnels.thenAccept { tunnels: ObservableSortedKeyedList<String?, ObservableTunnel?> -> + if (newTunnel != null) viewForTunnel(newTunnel, tunnels).setSingleSelected(true) + if (oldTunnel != null) viewForTunnel(oldTunnel, tunnels).setSingleSelected(false) + } + } + + private fun onTunnelDeletionFinished(count: Int, throwable: Throwable?) { + val message: String + if (throwable == null) { + message = resources.getQuantityString(R.plurals.delete_success, count, count) + } else { + val error = ErrorMessages.get(throwable) + message = resources.getQuantityString(R.plurals.delete_error, count, count, error) + Log.e(TAG, message, throwable) + } + showSnackbar(message) + } + + private fun onTunnelImportFinished(tunnels: List<ObservableTunnel>, throwables: Collection<Throwable>) { + var message: String? = null + for (throwable in throwables) { + val 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[0].name) + else if (tunnels.isEmpty() && throwables.size == 1) + else if (throwables.isEmpty()) + message = resources.getQuantityString(R.plurals.import_total_success, + tunnels.size, tunnels.size) + else if (!throwables.isEmpty()) + message = resources.getQuantityString(R.plurals.import_partial_success, + tunnels.size + throwables.size, + tunnels.size, tunnels.size + throwables.size) + showSnackbar(message) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + if (binding == null) { + return + } + binding!!.fragment = this + Application.getTunnelManager().tunnels.thenAccept { tunnels: ObservableSortedKeyedList<String?, ObservableTunnel?>? -> binding!!.tunnels = tunnels } + binding!!.rowConfigurationHandler = RowConfigurationHandler { binding: TunnelListItemBinding, tunnel: ObservableTunnel, position: Int -> + binding.fragment = this + binding.root.setOnClickListener { + if (actionMode == null) { + selectedTunnel = tunnel + } else { + actionModeListener.toggleItemChecked(position) + } + } + binding.root.setOnLongClickListener { + actionModeListener.toggleItemChecked(position) + true + } + if (actionMode != null) + (binding.root as MultiselectableRelativeLayout).setMultiSelected(actionModeListener.checkedItems.contains(position)) + else + (binding.root as MultiselectableRelativeLayout).setSingleSelected(selectedTunnel === tunnel) + } + } + + private fun showSnackbar(message: CharSequence?) { + if (binding != null) { + val snackbar = Snackbar.make(binding!!.mainContainer, message!!, Snackbar.LENGTH_LONG) + snackbar.anchorView = binding!!.createFab + snackbar.show() + } + } + + private fun viewForTunnel(tunnel: ObservableTunnel, tunnels: List<*>): MultiselectableRelativeLayout { + return binding!!.tunnelList.findViewHolderForAdapterPosition(tunnels.indexOf(tunnel))!!.itemView as MultiselectableRelativeLayout + } + + private inner class ActionModeListener : ActionMode.Callback { + val checkedItems: MutableCollection<Int> = HashSet() + private var resources: Resources? = null + fun getCheckedItems(): ArrayList<Int> { + return ArrayList(checkedItems) + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_action_delete -> { + val copyCheckedItems: Iterable<Int> = HashSet(checkedItems) + Application.getTunnelManager().tunnels.thenAccept { tunnels: ObservableSortedKeyedList<String, ObservableTunnel> -> + val tunnelsToDelete = ArrayList<ObservableTunnel>() + for (position in copyCheckedItems) tunnelsToDelete.add(tunnels[position]) + val futures = tunnelsToDelete + .map { obj -> obj.delete() } + .toTypedArray() + CompletableFuture.allOf(*futures as Array<out CompletableFuture<*>>) + .thenApply { futures.size } + .whenComplete { count: Int, throwable: Throwable? -> onTunnelDeletionFinished(count, throwable) } + } + checkedItems.clear() + mode.finish() + true + } + R.id.menu_action_select_all -> { + Application.getTunnelManager().tunnels.thenAccept { tunnels: ObservableSortedKeyedList<String?, ObservableTunnel?> -> + var i = 0 + while (i < tunnels.size) { + setItemChecked(i, true) + ++i + } + } + true + } + else -> false + } + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + actionMode = mode + if (activity != null) { + resources = activity!!.resources + } + mode.menuInflater.inflate(R.menu.tunnel_list_action_mode, menu) + binding!!.tunnelList.adapter!!.notifyDataSetChanged() + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + resources = null + checkedItems.clear() + binding!!.tunnelList.adapter!!.notifyDataSetChanged() + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + updateTitle(mode) + return false + } + + fun setItemChecked(position: Int, checked: Boolean) { + if (checked) { + checkedItems.add(position) + } else { + checkedItems.remove(position) + } + val adapter = if (binding == null) null else binding!!.tunnelList.adapter + if (actionMode == null && !checkedItems.isEmpty() && activity != null) { + (activity as AppCompatActivity?)!!.startSupportActionMode(this) + } else if (actionMode != null && checkedItems.isEmpty()) { + actionMode!!.finish() + } + adapter?.notifyItemChanged(position) + updateTitle(actionMode) + } + + fun toggleItemChecked(position: Int) { + setItemChecked(position, !checkedItems.contains(position)) + } + + private fun updateTitle(mode: ActionMode?) { + if (mode == null) { + return + } + val count = checkedItems.size + if (count == 0) { + mode.title = "" + } else { + mode.title = resources!!.getQuantityString(R.plurals.delete_title, count, count) + } + } + } + + companion object { + const val REQUEST_IMPORT = 1 + private const val REQUEST_TARGET_FRAGMENT = 2 + private val TAG = "WireGuard/" + TunnelListFragment::class.java.simpleName + } +} |