aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/main/java/com/wireguard/android/fragment
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/fragment')
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AddTunnelsSheet.kt104
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/AppListDialogFragment.kt170
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/BaseFragment.kt114
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/ConfigNamingDialogFragment.kt82
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt150
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt333
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelListFragment.kt342
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"
+ }
+}