aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt')
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelEditorFragment.kt333
1 files changed, 333 insertions, 0 deletions
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"
+ }
+}