diff options
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/fragment')
7 files changed, 1295 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..f077cbae --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt @@ -0,0 +1,104 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.pm.PackageManager +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.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.wireguard.android.R +import com.wireguard.android.util.resolveAttribute + +class AddTunnelsSheet : BottomSheetDialogFragment() { + + private var behavior: BottomSheetBehavior<FrameLayout>? = null + 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 onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + if (savedInstanceState != null) dismiss() + val view = inflater.inflate(R.layout.add_tunnels_bottom_sheet, container, false) + if (activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) != true) { + val qrcode = view.findViewById<View>(R.id.create_from_qrcode) + qrcode.isEnabled = false + qrcode.visibility = View.GONE + } + return view + } + + 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?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + 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(com.google.android.material.R.attr.colorSurface)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } + + private fun onRequestCreateConfig() { + setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_CREATE)) + } + + private fun onRequestImportConfig() { + setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_IMPORT)) + } + + private fun onRequestScanQRCode() { + setFragmentResult(REQUEST_KEY_NEW_TUNNEL, bundleOf(REQUEST_METHOD to REQUEST_SCAN)) + } + + companion object { + const val REQUEST_KEY_NEW_TUNNEL = "request_new_tunnel" + const val REQUEST_METHOD = "request_method" + const val REQUEST_CREATE = "request_create" + const val REQUEST_IMPORT = "request_import" + const val REQUEST_SCAN = "request_scan" + } +} 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..692dd809 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt @@ -0,0 +1,170 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.Manifest +import android.app.Dialog +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PackageInfoFlags +import android.os.Build +import android.os.Bundle +import android.widget.Button +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.databinding.Observable +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import com.wireguard.android.BR +import com.wireguard.android.R +import com.wireguard.android.databinding.AppListDialogFragmentBinding +import com.wireguard.android.databinding.ObservableKeyedArrayList +import com.wireguard.android.model.ApplicationData +import com.wireguard.android.util.ErrorMessages +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AppListDialogFragment : DialogFragment() { + private val appData = ObservableKeyedArrayList<String, ApplicationData>() + private var currentlySelectedApps = emptyList<String>() + private var initiallyExcluded = false + private var button: Button? = null + private var tabs: TabLayout? = null + + private fun loadData() { + val activity = activity ?: return + val pm = activity.packageManager + lifecycleScope.launch(Dispatchers.Default) { + try { + val applicationData: MutableList<ApplicationData> = ArrayList() + withContext(Dispatchers.IO) { + val packageInfos = getPackagesHoldingPermissions(pm, arrayOf(Manifest.permission.INTERNET)) + packageInfos.forEach { + val packageName = it.packageName + val appInfo = it.applicationInfo ?: return@forEach + val appData = + ApplicationData(appInfo.loadIcon(pm), appInfo.loadLabel(pm).toString(), packageName, currentlySelectedApps.contains(packageName)) + applicationData.add(appData) + appData.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable?, propertyId: Int) { + if (propertyId == BR.selected) + setButtonText() + } + }) + } + } + applicationData.sortWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + withContext(Dispatchers.Main.immediate) { + appData.clear() + appData.addAll(applicationData) + setButtonText() + } + } catch (e: Throwable) { + withContext(Dispatchers.Main.immediate) { + val error = ErrorMessages[e] + 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) + currentlySelectedApps = (arguments?.getStringArrayList(KEY_SELECTED_APPS) ?: emptyList()) + initiallyExcluded = arguments?.getBoolean(KEY_IS_EXCLUDED) ?: true + } + + private fun getPackagesHoldingPermissions(pm: PackageManager, permissions: Array<String>): List<PackageInfo> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackagesHoldingPermissions(permissions, PackageInfoFlags.of(0L)) + } else { + @Suppress("DEPRECATION") + pm.getPackagesHoldingPermissions(permissions, 0) + } + } + + private fun setButtonText() { + val numSelected = appData.count { it.isSelected } + button?.text = if (numSelected == 0) + getString(R.string.use_all_applications) + else when (tabs?.selectedTabPosition) { + 0 -> resources.getQuantityString(R.plurals.exclude_n_applications, numSelected, numSelected) + 1 -> resources.getQuantityString(R.plurals.include_n_applications, numSelected, numSelected) + else -> null + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val alertDialogBuilder = MaterialAlertDialogBuilder(requireActivity()) + val binding = AppListDialogFragmentBinding.inflate(requireActivity().layoutInflater, null, false) + binding.executePendingBindings() + alertDialogBuilder.setView(binding.root) + tabs = binding.tabs + tabs?.apply { + selectTab(binding.tabs.getTabAt(if (initiallyExcluded) 0 else 1)) + addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + override fun onTabSelected(tab: TabLayout.Tab?) = setButtonText() + }) + } + alertDialogBuilder.setPositiveButton(" ") { _, _ -> setSelectionAndDismiss() } + 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 { + button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + setButtonText() + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { _ -> + val selectAll = appData.none { it.isSelected } + appData.forEach { + it.isSelected = selectAll + } + } + } + return dialog + } + + private fun setSelectionAndDismiss() { + val selectedApps: MutableList<String> = ArrayList() + for (data in appData) { + if (data.isSelected) { + selectedApps.add(data.packageName) + } + } + setFragmentResult( + REQUEST_SELECTION, bundleOf( + KEY_SELECTED_APPS to selectedApps.toTypedArray(), + KEY_IS_EXCLUDED to (tabs?.selectedTabPosition == 0) + ) + ) + dismiss() + } + + companion object { + const val KEY_SELECTED_APPS = "selected_apps" + const val KEY_IS_EXCLUDED = "is_excluded" + const val REQUEST_SELECTION = "request_selection" + + fun newInstance(selectedApps: ArrayList<String?>?, isExcluded: Boolean): AppListDialogFragment { + val extras = Bundle() + extras.putStringArrayList(KEY_SELECTED_APPS, selectedApps) + extras.putBoolean(KEY_IS_EXCLUDED, isExcluded) + val fragment = AppListDialogFragment() + fragment.arguments = extras + return fragment + } + } +} 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..2e551f83 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt @@ -0,0 +1,114 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +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 +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 kotlinx.coroutines.launch + +/** + * 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 pendingTunnel: ObservableTunnel? = null + private var pendingTunnelUp: Boolean? = null + private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val tunnel = pendingTunnel + val checked = pendingTunnelUp + if (tunnel != null && checked != null) + setTunnelStateWithPermissionsResult(tunnel, checked) + pendingTunnel = null + pendingTunnelUp = null + } + + protected var selectedTunnel: ObservableTunnel? + get() = (activity as? BaseActivity)?.selectedTunnel + protected set(tunnel) { + (activity as? BaseActivity)?.selectedTunnel = tunnel + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (activity as? BaseActivity)?.addOnSelectedTunnelChangedListener(this) + } + + override fun onDetach() { + (activity as? BaseActivity)?.removeOnSelectedTunnelChangedListener(this) + 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 + } ?: return + val activity = activity ?: return + activity.lifecycleScope.launch { + if (Application.getBackend() is GoBackend) { + try { + val intent = GoBackend.VpnService.prepare(activity) + if (intent != null) { + pendingTunnel = tunnel + pendingTunnelUp = checked + permissionActivityResultLauncher.launch(intent) + return@launch + } + } catch (e: Throwable) { + val message = activity.getString(R.string.error_prepare, ErrorMessages[e]) + Snackbar.make(view, message, Snackbar.LENGTH_LONG) + .setAnchorView(view.findViewById(R.id.create_fab)) + .show() + Log.e(TAG, message, e) + } + } + setTunnelStateWithPermissionsResult(tunnel, checked) + } + } + + private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel, checked: Boolean) { + val activity = activity ?: return + activity.lifecycleScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.of(checked)) + } catch (e: Throwable) { + val error = ErrorMessages[e] + val messageResId = if (checked) R.string.error_up else R.string.error_down + val message = activity.getString(messageResId, error) + val view = view + if (view != null) + Snackbar.make(view, message, Snackbar.LENGTH_LONG) + .setAnchorView(view.findViewById(R.id.create_fab)) + .show() + else + Toast.makeText(activity, message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, e) + } + } + } + + companion object { + private const val TAG = "WireGuard/BaseFragment" + } +} 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..23da3fca --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt @@ -0,0 +1,82 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.app.Dialog +import android.os.Bundle +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +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 kotlinx.coroutines.launch +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 fun createTunnelAndDismiss() { + val binding = binding ?: return + val activity = activity ?: return + val name = binding.tunnelNameText.text.toString() + activity.lifecycleScope.launch { + try { + Application.getTunnelManager().create(name, config) + dismiss() + } catch (e: Throwable) { + binding.tunnelNameTextLayout.error = e.message + } + } + } + + 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: Throwable) { + when (e) { + is BadConfigException, is IOException -> throw IllegalArgumentException("Invalid config passed to ${javaClass.simpleName}", e) + else -> throw e + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + val alertDialogBuilder = MaterialAlertDialogBuilder(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) { _, _ -> createTunnelAndDismiss() } + alertDialogBuilder.setNegativeButton(R.string.cancel) { _, _ -> dismiss() } + val dialog = alertDialogBuilder.create() + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + return dialog + } + + companion object { + private const val KEY_CONFIG_TEXT = "config_text" + + 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.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt new file mode 100644 index 00000000..7731391d --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt @@ -0,0 +1,150 @@ +/* + * Copyright © 2017-2025 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.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.view.MenuProvider +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +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.util.QuantityFormatter +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Fragment that shows details about a specific tunnel. + */ +class TunnelDetailFragment : BaseFragment(), MenuProvider { + private var binding: TunnelDetailFragmentBinding? = null + private var lastState = Tunnel.State.TOGGLE + private var timerActive = true + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return false + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.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?.executePendingBindings() + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + timerActive = true + lifecycleScope.launch { + while (timerActive) { + updateStats() + delay(1000) + } + } + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { + val binding = binding ?: return + binding.tunnel = newTunnel + if (newTunnel == null) { + binding.config = null + } else { + lifecycleScope.launch { + try { + binding.config = newTunnel.getConfigAsync() + } catch (_: Throwable) { + binding.config = null + } + } + } + lastState = Tunnel.State.TOGGLE + lifecycleScope.launch { updateStats() } + } + + override fun onStop() { + timerActive = false + super.onStop() + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + binding ?: return + binding!!.fragment = this + onSelectedTunnelChanged(null, selectedTunnel) + super.onViewStateRestored(savedInstanceState) + } + + private suspend fun updateStats() { + val binding = binding ?: return + val tunnel = binding.tunnel ?: return + if (!isResumed) return + val state = tunnel.state + if (state != Tunnel.State.UP && lastState == state) return + lastState = state + try { + val statistics = tunnel.getStatisticsAsync() + for (i in 0 until binding.peersLayout.childCount) { + val peer: TunnelDetailPeerBinding = DataBindingUtil.getBinding(binding.peersLayout.getChildAt(i)) + ?: continue + val publicKey = peer.item!!.publicKey + val peerStats = statistics.peer(publicKey) + if (peerStats == null || (peerStats.rxBytes == 0L && peerStats.txBytes == 0L)) { + peer.transferLabel.visibility = View.GONE + peer.transferText.visibility = View.GONE + } else { + peer.transferText.text = getString( + R.string.transfer_rx_tx, + QuantityFormatter.formatBytes(peerStats.rxBytes), + QuantityFormatter.formatBytes(peerStats.txBytes) + ) + peer.transferLabel.visibility = View.VISIBLE + peer.transferText.visibility = View.VISIBLE + } + if (peerStats == null || peerStats.latestHandshakeEpochMillis == 0L) { + peer.latestHandshakeLabel.visibility = View.GONE + peer.latestHandshakeText.visibility = View.GONE + } else { + peer.latestHandshakeText.text = QuantityFormatter.formatEpochAgo(peerStats.latestHandshakeEpochMillis) + peer.latestHandshakeLabel.visibility = View.VISIBLE + peer.latestHandshakeText.visibility = View.VISIBLE + } + } + } catch (e: Throwable) { + 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 + peer.latestHandshakeLabel.visibility = View.GONE + peer.latestHandshakeText.visibility = View.GONE + } + } + } +} 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..f5d28ad5 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt @@ -0,0 +1,333 @@ +/* + * Copyright © 2017-2025 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.text.InputType +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.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.Toast +import androidx.core.os.BundleCompat +import androidx.core.view.MenuProvider +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +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.model.ObservableTunnel +import com.wireguard.android.util.AdminKnobs +import com.wireguard.android.util.BiometricAuthenticator +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.viewmodel.ConfigProxy +import com.wireguard.config.Config +import kotlinx.coroutines.launch + +/** + * Fragment for editing a WireGuard configuration. + */ +class TunnelEditorFragment : BaseFragment(), MenuProvider { + private var haveShownKeys = false + private var binding: TunnelEditorFragmentBinding? = null + private var tunnel: ObservableTunnel? = null + + private fun onConfigLoaded(config: Config) { + binding?.config = ConfigProxy(config) + } + + private fun onConfigSaved(savedTunnel: Tunnel, throwable: Throwable?) { + val ctx = activity ?: Application.get() + if (throwable == null) { + val message = ctx.getString(R.string.config_save_success, savedTunnel.name) + Log.d(TAG, message) + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + onFinished() + } else { + val error = ErrorMessages[throwable] + val message = ctx.getString(R.string.config_save_error, savedTunnel.name, error) + Log.e(TAG, message, throwable) + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show() + else + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.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() + privateKeyTextLayout.setEndIconOnClickListener { config?.`interface`?.generateKeyPair() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + override fun onDestroyView() { + activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + binding = null + super.onDestroyView() + } + + 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 + ) + } + parentFragmentManager.popBackStackImmediate() + + // If we just made a new one, save it to select the details page. + if (selectedTunnel != tunnel) + selectedTunnel = tunnel + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.menu_action_save) { + binding ?: return false + val newConfig = try { + binding!!.config!!.resolve() + } catch (e: Throwable) { + val error = ErrorMessages[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 + } + val activity = requireActivity() + activity.lifecycleScope.launch { + when { + tunnel == null -> { + Log.d(TAG, "Attempting to create new tunnel " + binding!!.name) + val manager = Application.getTunnelManager() + try { + onTunnelCreated(manager.create(binding!!.name!!, newConfig), null) + } catch (e: Throwable) { + onTunnelCreated(null, e) + } + } + + tunnel!!.name != binding!!.name -> { + Log.d(TAG, "Attempting to rename tunnel to " + binding!!.name) + try { + tunnel!!.setNameAsync(binding!!.name!!) + onTunnelRenamed(tunnel!!, newConfig, null) + } catch (e: Throwable) { + onTunnelRenamed(tunnel!!, newConfig, e) + } + } + + else -> { + Log.d(TAG, "Attempting to save config of " + tunnel!!.name) + try { + tunnel!!.setConfigAsync(newConfig) + onConfigSaved(tunnel!!, null) + } catch (e: Throwable) { + onConfigSaved(tunnel!!, e) + } + } + } + } + return true + } + return false + } + + @Suppress("UNUSED_PARAMETER") + fun onRequestSetExcludedIncludedApplications(view: View?) { + if (binding != null) { + var isExcluded = true + var selectedApps = ArrayList(binding!!.config!!.`interface`.excludedApplications) + if (selectedApps.isEmpty()) { + selectedApps = ArrayList(binding!!.config!!.`interface`.includedApplications) + if (selectedApps.isNotEmpty()) + isExcluded = false + } + val fragment = AppListDialogFragment.newInstance(selectedApps, isExcluded) + childFragmentManager.setFragmentResultListener(AppListDialogFragment.REQUEST_SELECTION, viewLifecycleOwner) { _, bundle -> + requireNotNull(binding) { "Tried to set excluded/included apps while no view was loaded" } + val newSelections = requireNotNull(bundle.getStringArray(AppListDialogFragment.KEY_SELECTED_APPS)) + val excluded = requireNotNull(bundle.getBoolean(AppListDialogFragment.KEY_IS_EXCLUDED)) + if (excluded) { + binding!!.config!!.`interface`.includedApplications.clear() + binding!!.config!!.`interface`.excludedApplications.apply { + clear() + addAll(newSelections) + } + } else { + binding!!.config!!.`interface`.excludedApplications.clear() + binding!!.config!!.`interface`.includedApplications.apply { + clear() + addAll(newSelections) + } + } + } + fragment.show(childFragmentManager, 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 + lifecycleScope.launch { + try { + onConfigLoaded(tunnel!!.getConfigAsync()) + } catch (_: Throwable) { + } + } + } else { + binding!!.name = "" + } + } + + private fun onTunnelCreated(newTunnel: ObservableTunnel?, throwable: Throwable?) { + val ctx = activity ?: Application.get() + if (throwable == null) { + tunnel = newTunnel + val message = ctx.getString(R.string.tunnel_create_success, tunnel!!.name) + Log.d(TAG, message) + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + onFinished() + } else { + val error = ErrorMessages[throwable] + val message = ctx.getString(R.string.tunnel_create_error, error) + Log.e(TAG, message, throwable) + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show() + else + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + private suspend fun onTunnelRenamed( + renamedTunnel: ObservableTunnel, newConfig: Config, + throwable: Throwable? + ) { + val ctx = activity ?: Application.get() + if (throwable == null) { + val message = ctx.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) + try { + renamedTunnel.setConfigAsync(newConfig) + onConfigSaved(renamedTunnel, null) + } catch (e: Throwable) { + onConfigSaved(renamedTunnel, e) + } + } else { + val error = ErrorMessages[throwable] + val message = ctx.getString(R.string.tunnel_rename_error, error) + Log.e(TAG, message, throwable) + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG).show() + else + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + binding ?: return + binding!!.fragment = this + if (savedInstanceState == null) { + onSelectedTunnelChanged(null, selectedTunnel) + } else { + tunnel = selectedTunnel + val config = BundleCompat.getParcelable(savedInstanceState, KEY_LOCAL_CONFIG, ConfigProxy::class.java)!! + val originalName = savedInstanceState.getString(KEY_ORIGINAL_NAME) + if (tunnel != null && tunnel!!.name != originalName) onSelectedTunnelChanged(null, tunnel) else binding!!.config = config + } + super.onViewStateRestored(savedInstanceState) + } + + private var showingAuthenticator = false + + fun onKeyClick(view: View) = onKeyFocusChange(view, true) + + fun onKeyFocusChange(view: View, isFocused: Boolean) { + if (!isFocused || showingAuthenticator) return + val edit = view as? EditText ?: return + if (edit.inputType == InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) return + if (!haveShownKeys && edit.text.isNotEmpty()) { + if (AdminKnobs.disableConfigExport) return + showingAuthenticator = true + BiometricAuthenticator.authenticate(R.string.biometric_prompt_private_key_title, this) { + showingAuthenticator = false + when (it) { + is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + haveShownKeys = true + showPrivateKey(edit) + } + + is BiometricAuthenticator.Result.Failure -> { + Snackbar.make( + binding!!.mainContainer, + it.message, + Snackbar.LENGTH_SHORT + ).show() + } + + is BiometricAuthenticator.Result.Cancelled -> {} + } + } + } else { + showPrivateKey(edit) + } + } + + private fun showPrivateKey(edit: EditText) { + activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + edit.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } + + companion object { + private const val KEY_LOCAL_CONFIG = "local_config" + private const val KEY_ORIGINAL_NAME = "original_name" + private const val TAG = "WireGuard/TunnelEditorFragment" + } +} 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..119b6afe --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt @@ -0,0 +1,342 @@ +/* + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.fragment + +import android.content.Intent +import android.content.res.Resources +import android.os.Bundle +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 android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.google.zxing.qrcode.QRCodeReader +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +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.model.ObservableTunnel +import com.wireguard.android.updater.SnackbarUpdateShower +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.QrCodeFromFileScanner +import com.wireguard.android.util.TunnelImporter +import com.wireguard.android.widget.MultiselectableRelativeLayout +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch + +/** + * 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 backPressedCallback: OnBackPressedCallback? = null + private var binding: TunnelListFragmentBinding? = null + private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data -> + if (data == null) return@registerForActivityResult + val activity = activity ?: return@registerForActivityResult + val contentResolver = activity.contentResolver ?: return@registerForActivityResult + activity.lifecycleScope.launch { + if (QrCodeFromFileScanner.validContentType(contentResolver, data)) { + try { + val qrCodeFromFileScanner = QrCodeFromFileScanner(contentResolver, QRCodeReader()) + val result = qrCodeFromFileScanner.scan(data) + TunnelImporter.importTunnel(parentFragmentManager, result.text) { showSnackbar(it) } + } catch (e: Exception) { + val error = ErrorMessages[e] + val message = Application.get().resources.getString(R.string.import_error, error) + Log.e(TAG, message, e) + showSnackbar(message) + } + } else { + TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) } + } + } + } + + private val qrImportResultLauncher = registerForActivityResult(ScanContract()) { result -> + val qrCode = result.contents + val activity = activity + if (qrCode != null && activity != null) { + activity.lifecycleScope.launch { TunnelImporter.importTunnel(parentFragmentManager, qrCode) { showSnackbar(it) } } + } + } + + private val snackbarUpdateShower = SnackbarUpdateShower(this) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState != null) { + val checkedItems = savedInstanceState.getIntegerArrayList(CHECKED_ITEMS) + if (checkedItems != null) { + for (i in checkedItems) actionModeListener.setItemChecked(i, true) + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = TunnelListFragmentBinding.inflate(inflater, container, false) + val bottomSheet = AddTunnelsSheet() + binding?.apply { + createFab.setOnClickListener { + if (childFragmentManager.findFragmentByTag("BOTTOM_SHEET") != null) + return@setOnClickListener + childFragmentManager.setFragmentResultListener(AddTunnelsSheet.REQUEST_KEY_NEW_TUNNEL, viewLifecycleOwner) { _, bundle -> + when (bundle.getString(AddTunnelsSheet.REQUEST_METHOD)) { + AddTunnelsSheet.REQUEST_CREATE -> { + startActivity(Intent(requireActivity(), TunnelCreatorActivity::class.java)) + } + + AddTunnelsSheet.REQUEST_IMPORT -> { + tunnelFileImportResultLauncher.launch("*/*") + } + + AddTunnelsSheet.REQUEST_SCAN -> { + qrImportResultLauncher.launch( + ScanOptions() + .setOrientationLocked(false) + .setBeepEnabled(false) + .setPrompt(getString(R.string.qr_code_hint)) + ) + } + } + } + bottomSheet.showNow(childFragmentManager, "BOTTOM_SHEET") + } + executePendingBindings() + snackbarUpdateShower.attach(mainContainer, createFab) + } + backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() } + backPressedCallback?.isEnabled = false + + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putIntegerArrayList(CHECKED_ITEMS, actionModeListener.getCheckedItems()) + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { + binding ?: return + lifecycleScope.launch { + val tunnels = Application.getTunnelManager().getTunnels() + 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 + val ctx = activity ?: Application.get() + if (throwable == null) { + message = ctx.resources.getQuantityString(R.plurals.delete_success, count, count) + } else { + val error = ErrorMessages[throwable] + message = ctx.resources.getQuantityString(R.plurals.delete_error, count, count, error) + Log.e(TAG, message, throwable) + } + showSnackbar(message) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + binding ?: return + binding!!.fragment = this + lifecycleScope.launch { binding!!.tunnels = Application.getTunnelManager().getTunnels() } + binding!!.rowConfigurationHandler = object : RowConfigurationHandler<TunnelListItemBinding, ObservableTunnel> { + override fun onConfigureRow(binding: TunnelListItemBinding, item: ObservableTunnel, position: Int) { + binding.fragment = this@TunnelListFragment + binding.root.setOnClickListener { + if (actionMode == null) { + selectedTunnel = item + } 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 == item) + } + } + } + + private fun showSnackbar(message: CharSequence) { + val binding = binding + if (binding != null) + Snackbar.make(binding.mainContainer, message, Snackbar.LENGTH_LONG) + .setAnchorView(binding.createFab) + .show() + else + Toast.makeText(activity ?: Application.get(), message, Toast.LENGTH_SHORT).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 activity = activity ?: return true + val copyCheckedItems = HashSet(checkedItems) + binding?.createFab?.apply { + visibility = View.VISIBLE + scaleX = 1f + scaleY = 1f + } + activity.lifecycleScope.launch { + try { + val tunnels = Application.getTunnelManager().getTunnels() + val tunnelsToDelete = ArrayList<ObservableTunnel>() + for (position in copyCheckedItems) tunnelsToDelete.add(tunnels[position]) + val futures = tunnelsToDelete.map { async(SupervisorJob()) { it.deleteAsync() } } + onTunnelDeletionFinished(futures.awaitAll().size, null) + } catch (e: Throwable) { + onTunnelDeletionFinished(0, e) + } + } + checkedItems.clear() + mode.finish() + true + } + + R.id.menu_action_select_all -> { + lifecycleScope.launch { + val tunnels = Application.getTunnelManager().getTunnels() + for (i in 0 until tunnels.size) { + setItemChecked(i, true) + } + } + true + } + + else -> false + } + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + actionMode = mode + backPressedCallback?.isEnabled = true + if (activity != null) { + resources = activity!!.resources + } + animateFab(binding?.createFab, false) + mode.menuInflater.inflate(R.menu.tunnel_list_action_mode, menu) + binding?.tunnelList?.adapter?.notifyDataSetChanged() + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + backPressedCallback?.isEnabled = false + resources = null + animateFab(binding?.createFab, true) + 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) + } + } + + private fun animateFab(view: View?, show: Boolean) { + view ?: return + val animation = AnimationUtils.loadAnimation( + context, if (show) R.anim.scale_up else R.anim.scale_down + ) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationRepeat(animation: Animation?) { + } + + override fun onAnimationEnd(animation: Animation?) { + if (!show) view.visibility = View.GONE + } + + override fun onAnimationStart(animation: Animation?) { + if (show) view.visibility = View.VISIBLE + } + }) + view.startAnimation(animation) + } + } + + companion object { + private const val CHECKED_ITEMS = "CHECKED_ITEMS" + private const val TAG = "WireGuard/TunnelListFragment" + } +} |